📝

手続き型から関数型への書き換え 言語別ガイド

2024/02/18に公開

はじめに

この記事では、手続き型プログラミングと関数型プログラミングの違いを明らかにし、複数の言語を通じて、同じ問題を解決するための両アプローチを示します。

手続き型と関数型の基本的な違い

手続き型プログラミングと関数型プログラミングは、コードの書き方とプログラムの設計において根本的に異なるアプローチを取ります。これら二つのスタイルの基本的な違いを理解することは、プログラミングの柔軟性を高め、より効果的なコードを書く上で重要です。

手続き型プログラミング

手続き型プログラミングは、命令の列としてプログラムを考えます。このアプローチでは、データ構造を操作するために一連の手続き(関数やサブルーチン)を作成し、プログラムの状態を変更するためにこれらの手続きを実行します。手続き型プログラミングの特徴は、プログラムがどのように実行されるか(アルゴリズムと手順)に焦点を当てている点にあります。C 言語や Java(オブジェクト指向の側面を除く)などが、このパラダイムの典型的な例です。

関数型プログラミング

関数型プログラミングは、計算を数学的関数の評価として扱い、副作用を避けることに焦点を当てます。このパラダイムでは、不変性(データが作成後に変更されないこと)と純粋関数(同じ入力に対して常に同じ出力を返し、副作用がない関数)の使用が推奨されます。関数型プログラミングは、データの流れと変換によりプログラムを表現し、"何を"行うかに焦点を当てます。Haskell や Erlang、そして JavaScript や Python のような多目的言語の関数型の特性を利用する場合などがこのパラダイムに該当します。

記事の目的と概要

この記事では、手続き型プログラミングと関数型プログラミングの違いを具体的な例を通じて探り、それぞれのアプローチがプログラム設計にどのように影響を与えるかを示します。複数の言語で同じ問題を解決する異なるアプローチを比較し、それぞれの利点と適用場面について議論します。

https://github.com/tonbiattack/functional-or-procedural

例題 1

  • リスト内の数値の平均を計算する

手続き型の Java コード

手続き型では、ループを使用してリスト内の数値の合計を計算し、その後でリストのサイズで割って平均値を求めます。

import java.util.Arrays;
import java.util.List;

public class AverageCalculatorProcedural {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        double sum = 0;
        for (int number : numbers) {
            sum += number;
        }
        double average = sum / numbers.size();

        System.out.println("Average: " + average);
    }
}

関数型の Java コード

関数型では、stream を使用して数値の合計と平均を計算します。これにより、コードがより宣言的になり、副作用が減少します。

import java.util.Arrays;
import java.util.List;

public class AverageCalculatorFunctional {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        double average = numbers.stream()
                .mapToInt(Integer::intValue)
                .average()
                .orElse(Double.NaN);

        System.out.println("Average: " + average);
    }
}

  1. numbers.stream():
  • numbers リストからストリームを生成します。ストリームは、連続する要素に対する操作をサポートする Java API で、関数型プログラミングスタイルを実現するためのものです。
  1. .mapToInt(Integer::intValue):
  • ストリーム内の各要素(この場合は Integer オブジェクト)を int 値にマッピングします。Integer::intValue はメソッド参照で、各 Integer オブジェクトに対して intValue()メソッドを呼び出して、そのプリミティブ int 値を取得します。これにより、Integer のストリームが int のストリームに変換されます。
  1. .average():
  • ストリームの平均を計算します。この操作は OptionalDouble を返します。OptionalDouble は、結果が存在するかもしれないし、しないかもしれない場合に使用されます。このケースでは、ストリームが空でなければ平均が計算されます。
  1. .orElse(Double.NaN):
  • average()メソッドによって返された OptionalDouble が値を持っている場合、その値を取得します。値が存在しない場合(例えば、リストが空の場合)、Double.NaN("Not a Number")を返します。これは、平均が計算できなかった場合に適切なフォールバック値を提供します。

手続き型の JavaScript コード

const numbers = [1, 2, 3, 4, 5];

let sum = 0;
for (let i = 0; i < numbers.length; i++) {
  sum += numbers[i];
}
const average = sum / numbers.length;

console.log("Average:", average);

関数型の JavaScript コード

const numbers = [1, 2, 3, 4, 5];

const sum = numbers.reduce((acc, current) => acc + current, 0);
const average = sum / numbers.length;

console.log("Average:", average);
  • reduce メソッド: 配列 numbers に対して reduce メソッドを呼び出します。reduce は配列の各要素に対して指定されたリデューサー関数を実行し、単一の結果値(この場合は合計値)を生成します。リデューサー関数は、累積値(acc)と配列の現在値(current)を引数に取り、次の累積値を返します。

  • 初期値: reduce の第二引数には、累積値の初期値 0 を指定しています。これにより、数値の合計の計算が 0 から始まります。

  • 平均値の計算: 合計値 sum を配列 numbers の長さで割ることで、平均値を求めます。

手続き型の TypeScript コード

const numbers: number[] = [1, 2, 3, 4, 5];

let sum: number = 0;
for (let i = 0; i < numbers.length; i++) {
  sum += numbers[i];
}
const average: number = sum / numbers.length;

console.log("Average:", average);

関数型の TypeScript コード

const numbers: number[] = [1, 2, 3, 4, 5];

const sum: number = numbers.reduce((acc: number, current: number) => acc + current,0);
const average: number = sum / numbers.length;

console.log("Average:", average);

手続き型の Python コード

numbers = [1, 2, 3, 4, 5]

sum = 0
for number in numbers:
    sum += number
average = sum / len(numbers)

print("Average:", average)

関数型の Python コード

numbers = [1, 2, 3, 4, 5]

total = sum(numbers)
average = total / len(numbers)

print("Average:", average)

手続き型の Rust コード

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    let mut sum = 0;
    for number in &numbers {
        sum += number;
    }
    let average = sum as f64 / numbers.len() as f64;
    println!("Average: {}", average);
}

関数型の Rust コード

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    let sum: i32 = numbers.iter().sum();
    let average = sum as f64 / numbers.len() as f64;
    println!("Average: {}", average);
}

iter() メソッドでイテレータを作成し、sum() メソッドで数値の合計を計算しています。その後、リストのサイズで割って平均値を求めています。

例題 2

  • 配列の要素を二倍にする

手続き型の Python コード

numbers = [1, 2, 3, 4, 5]
doubled_numbers = []
for number in numbers:
    doubled_numbers.append(number * 2)
print(doubled_numbers)

関数型の Python コード

numbers = [1, 2, 3, 4, 5]
doubled_numbers = map(lambda x: x * 2, numbers)
print(list(doubled_numbers))

  1. map 関数とラムダ式の使用:
    map 関数は 2 つの引数を取ります。最初の引数は関数、二番目の引数はイテラブル(この場合は numbers リスト)です。
    ラムダ式 lambda x: x \* 2 は匿名関数を定義しており、これによりリストの各要素が引数 x として受け取られ、その要素を 2 倍にした値が返されます。
    map 関数は、numbers リストの各要素に対してラムダ関数を適用し、結果として得られる値からなる新しいイテレータを返します。

  2. list 関数によるイテレータのリスト化:
    map 関数の結果はイテレータであり、これを list 関数に渡すことでリストに変換します。これにより、doubled_numbers の内容をリストとして取得し、それを出力できるようになります。

手続き型の Java コード

import java.util.ArrayList;
import java.util.List;

public class DoubleTheArrayElementsProcedural {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);
        numbers.add(4);
        numbers.add(5);

        List<Integer> doubledNumbers = new ArrayList<>();
        for (int number : numbers) {
            doubledNumbers.add(number * 2);
        }

        System.out.println(doubledNumbers);
    }
}


関数型の Java コード

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class DoubleTheArrayElementsFunctional {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        List<Integer> doubledNumbers = numbers.stream()
                .map(number -> number * 2)
                .collect(Collectors.toList());

        System.out.println(doubledNumbers);
    }
}

  • numbers.stream(): リスト numbers からストリームを生成します。
  • .map(number -> number * 2): ストリームの各要素にラムダ式 number -> number * 2 を適用し、各要素を二倍にします。
  • .collect(Collectors.toList()): 変換されたストリームの要素を新しいリストに集めます。

手続き型の TypeScript コード

function doubleTheArrayElementsProcedural(numbers: number[]): number[] {
    const doubledNumbers: number[] = [];
    for (const number of numbers) {
        doubledNumbers.push(number * 2);
    }
    return doubledNumbers;
}

const numbers = [1, 2, 3, 4, 5];
const doubledNumbers = doubleTheArrayElementsProcedural(numbers);
console.log(doubledNumbers);

関数型の TypeScript コード

function doubleTheArrayElementsFunctional(numbers: number[]): number[] {
    return numbers.map(number => number * 2);
}

const numbers = [1, 2, 3, 4, 5];
const doubledNumbers = doubleTheArrayElementsFunctional(numbers);
console.log(doubledNumbers);
  • return numbers.map(number => number _ 2);: map メソッドを使用して配列の各要素にラムダ式 number => number _ 2 を適用し、各要素を二倍にします。map メソッドは配列の各要素に対して指定された関数を実行し、その結果を新しい配列に格納します。

例題 3

  • 特定のクラスから特定の ID を抜き出す。

Java のクラス

public class Person {
    private int id;
    private String name;

    public Person(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}

手続き型の Java コード

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Person> people = new ArrayList<>();
        people.add(new Person(1, "Alice"));
        people.add(new Person(2, "Bob"));
        people.add(new Person(3, "Charlie"));

        int targetId = 2;
        Person targetPerson = null;
        for (Person person : people) {
            if (person.getId() == targetId) {
                targetPerson = person;
                break;
            }
        }

        if (targetPerson != null) {
            System.out.println("Found person with ID " + targetId + ": " + targetPerson.getName());
        } else {
            System.out.println("No person found with ID " + targetId);
        }
    }
}

関数型の Java コード

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class Main {
    public static void main(String[] args) {
        List<Person> people = new ArrayList<>();
        people.add(new Person(1, "Alice"));
        people.add(new Person(2, "Bob"));
        people.add(new Person(3, "Charlie"));

        int targetId = 2;
        Optional<Person> targetPerson = people.stream()
                                              .filter(person -> person.getId() == targetId)
                                              .findFirst();

        targetPerson.ifPresentOrElse(
            person -> System.out.println("Found person with ID " + targetId + ": " + person.getName()),
            () -> System.out.println("No person found with ID " + targetId)
        );
    }
}

このコードでは、stream()メソッドを使用して people リストからストリームを作成し、filter()メソッドで ID が targetId と一致する要素のみを選択しています。findFirst()メソッドは、条件に一致する最初の要素を返す Optional<Person>オブジェクトを生成します。最後に、ifPresentOrElse()メソッドを使用して、見つかった場合はその人の名前を出力し、見つからなかった場合は適切なメッセージを出力します。

手続き型の Python コード

class Person:
    def __init__(self, id, name):
        self.id = id
        self.name = name

people = [
    Person(1, "Alice"),
    Person(2, "Bob"),
    Person(3, "Charlie")
]

target_id = 2
target_person = None
for person in people:
    if person.id == target_id:
        target_person = person
        break

if target_person:
    print(f"Found person with ID {target_id}: {target_person.name}")
else:
    print(f"No person found with ID {target_id}")

関数型の Python コード

class Person:
    def __init__(self, id, name):
        self.id = id
        self.name = name

people = [
    Person(1, "Alice"),
    Person(2, "Bob"),
    Person(3, "Charlie")
]

target_id = 2
target_person = next((person for person in people if person.id == target_id), None)

if target_person:
    print(f"Found person with ID {target_id}: {target_person.name}")
else:
    print(f"No person found with ID {target_id}")

target_person = next((person for person in people if person.id == target_id), None): ここでジェネレータ式(person for person in people if person.id == target_id)を使用して、people リストの中から id が target_id と一致する最初の Person オブジェクトを取得しています。next()関数はジェネレータから次の要素を取得しますが、一致する要素がない場合は None を返します。

手続き型の TypeScript コード

class Person {
    constructor(public id: number, public name: string) {}
}

const people: Person[] = [
    new Person(1, "Alice"),
    new Person(2, "Bob"),
    new Person(3, "Charlie")
];

const targetId = 2;
let targetPerson: Person | undefined;

for (const person of people) {
    if (person.id === targetId) {
        targetPerson = person;
        break;
    }
}

if (targetPerson) {
    console.log(`Found person with ID ${targetId}: ${targetPerson.name}`);
} else {
    console.log(`No person found with ID ${targetId}`);
}

関数型の TypeScript コード

class Person {
    constructor(public id: number, public name: string) {}
}

const people: Person[] = [
    new Person(1, "Alice"),
    new Person(2, "Bob"),
    new Person(3, "Charlie")
];

const targetId = 2;
const targetPerson = people.find(person => person.id === targetId);

if (targetPerson) {
    console.log(`Found person with ID ${targetId}: ${targetPerson.name}`);
} else {
    console.log(`No person found with ID ${targetId}`);
}

find メソッドを使用して、ID が targetId と一致する最初の Person オブジェクトを配列から探しています。見つかった場合はその人の名前を出力し、見つからなかった場合は適切なメッセージを出力しています。

例題 4

  • 辞書のキーに基づく値の更新

手続き型の Java コード

手続き型では、ループを使用してリスト内の数値の合計を計算し、その後でリストのサイズで割って平均値を求めます。

import java.util.HashMap;
import java.util.Map;

public class MapUpdateProcedural {
    public static void main(String[] args) {
        Map<String, Integer> scores = new HashMap<>();
        scores.put("Alice", 90);
        scores.put("Bob", 80);
        scores.put("Charlie", 85);

        // キーをチェックし、条件に一致する場合は値を更新する
        for (Map.Entry<String, Integer> entry : scores.entrySet()) {
            if ("Bob".equals(entry.getKey())) {
                scores.put(entry.getKey(), entry.getValue() + 5); // Bobのスコアを5点増やす
            }
        }

        System.out.println("Updated scores: " + scores);
    }
}

関数型の Java コード

Java での"関数型"アプローチは、ストリーム API を用いて Map の内容を更新する形で実現しますが、直接 Map をストリーム化して処理することはできません。代わりに、更新されたエントリを持つ新しい Map を作成することが一般的です。

import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;

public class MapUpdateFunctional {
    public static void main(String[] args) {
        Map<String, Integer> scores = new HashMap<>();
        scores.put("Alice", 90);
        scores.put("Bob", 80);
        scores.put("Charlie", 85);

        // 条件に一致するキーの値を更新した新しいMapを作成する
        Map<String, Integer> updatedScores = scores.entrySet().stream()
                .collect(Collectors.toMap(
                        Map.Entry::getKey,
                        entry -> "Bob".equals(entry.getKey()) ? entry.getValue() + 5 : entry.getValue()));

        System.out.println("Updated scores: " + updatedScores);
    }
}
  • scores.entrySet().stream(): scores マップのエントリセット(キーと値のペアのセット)をストリームに変換しています。
  • .collect(Collectors.toMap(...)): ストリームの各エントリに対して操作を行い、結果を新しい Map に収集しています。
  • Map.Entry::getKey: エントリのキー(この場合は人物の名前)を取得するためのメソッド参照です。
  • entry -> "Bob".equals(entry.getKey()) ? entry.getValue() + 5 : entry.getValue(): エントリの値(この場合はスコア)を更新するためのラムダ式です。キーが"Bob"に一致する場合、値(スコア)に 5 を加えます。それ以外の場合は値をそのまま使用します。

手続き型の TypeScript コード

let scores: Map<string, number> = new Map([
    ["Alice", 90],
    ["Bob", 80],
    ["Charlie", 85]
]);

// キーをチェックし、条件に一致する場合は値を更新する
scores.forEach((value, key) => {
    if (key === "Bob") {
        scores.set(key, value + 5);
    }
});

console.log("Updated scores:", scores);

関数型の TypeScript コード

TypeScript では、Map を直接ストリーム化するような関数はありませんが、Array.from を使用して Map のエントリを配列に変換し、その配列を処理することで関数型のアプローチを実現できます。

let scores: Map<string, number> = new Map([
    ["Alice", 90],
    ["Bob", 80],
    ["Charlie", 85]
]);

// 条件に一致するキーの値を更新した新しいMapを作成する
let updatedScores: Map<string, number> = new Map(
    Array.from(scores.entries()).map(([key, value]) => {
        return [key, key === "Bob" ? value + 5 : value];
    })
);

console.log("Updated scores:", updatedScores);
  • Array.from(scores.entries()): scores マップのエントリ(キーと値のペア)を配列に変換しています。
  • .map(([key, value]) => { ... }): 変換された配列の各要素(エントリ)に対して、アロー関数を適用しています。この関数は、エントリのキーと値を分割代入で受け取り、新しい値を計算しています。
  • key === "Bob" ? value + 5 : value: 三項演算子を使用して、キーが"Bob"に一致する場合は値(スコア)に 5 を加え、それ以外の場合は値をそのまま使用しています。
  • new Map(...): 更新されたエントリの配列を新しい Map に変換しています。この新しい Map は変数 updatedScores に割り当てられています。

手続き型の Python コード

scores = {
    "Alice": 90,
    "Bob": 80,
    "Charlie": 85
}

# キーをチェックし、条件に一致する場合は値を更新する
if "Bob" in scores:
    scores["Bob"] += 5

print("Updated scores:", scores)

関数型の Python コード

scores = {
    "Alice": 90,
    "Bob": 80,
    "Charlie": 85
}

# 条件に一致するキーの値を更新した新しい辞書を作成する
updated_scores = {key: value + 5 if key == "Bob" else value for key, value in scores.items()}

print("Updated scores:", updated_scores)

updated_scores = {key: value + 5 if key == "Bob" else value for key, value in scores.items()}: 辞書のコンプリヘンションを使用して新しい辞書 updated_scores を作成しています。このコンプリヘンションは、元の辞書 scores の各キーと値に対してループを回し、キーが"Bob"に一致する場合は値に 5 を加え、それ以外の場合は値をそのまま使用しています。結果として得られるキーと値のペアから新しい辞書が作成されます。

例題 5

  • 文字列のリストから特定の文字で始まる要素を抽出する

手続き型の Java コード

import java.util.ArrayList;
import java.util.List;

public class StringFilterProcedural {
    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob", "Charlie", "David");
        List<String> filteredNames = new ArrayList<>();
        for (String name : names) {
            if (name.startsWith("C")) {
                filteredNames.add(name);
            }
        }
        System.out.println("Names starting with 'C': " + filteredNames);
    }
}

関数型の Java コード

import java.util.List;
import java.util.stream.Collectors;

public class StringFilterFunctional {
    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob", "Charlie", "David");
        List<String> filteredNames = names.stream()
                                          .filter(name -> name.startsWith("C"))
                                          .collect(Collectors.toList());
        System.out.println("Names starting with 'C': " + filteredNames);
    }
}

手続き型の TypeScript コード

let scores: Map<string, number> = new Map([
    ["Alice", 90],
    ["Bob", 80],
    ["Charlie", 85]
]);

// キーをチェックし、条件に一致する場合は値を更新する
scores.forEach((value, key) => {
    if (key === "Bob") {
        scores.set(key, value + 5);
    }
});

console.log("Updated scores:", scores);

関数型の TypeScript コード

const names: string[] = ["Alice", "Bob", "Charlie", "David"];
const filteredNames: string[] = names.filter((name: string) => name.startsWith("C"));
console.log("Names starting with 'C':", filteredNames);

手続き型の Python コード

names = ["Alice", "Bob", "Charlie", "David"]
filtered_names = []
for name in names:
    if name.startswith("C"):
        filtered_names.append(name)
print("Names starting with 'C':", filtered_names)

関数型の Python コード

names = ["Alice", "Bob", "Charlie", "David"]
filtered_names = list(filter(lambda name: name.startswith("C"), names))
print("Names starting with 'C':", filtered_names)

filtered_names = list(filter(lambda name: name.startswith("C"), names)):

filter 関数は、指定された関数(この場合はラムダ式)が True を返すすべての要素を保持するイテレータを返します。
ラムダ式 lambda name: name.startswith("C") は、各要素(name)が "C" で始まるかどうかをチェックします。startswith メソッドは、文字列が指定された接頭辞で始まる場合に True を返します。
filter 関数の結果はイテレータなので、list 関数を使ってリストに変換します。

手続き型と関数型の比較

プログラミングにおいて、手続き型と関数型は二つの主要なパラダイムを代表しています。これらのスタイルは、コードの設計と実装において異なるアプローチをとります。以下に、両パラダイムの利点と欠点を分析します。

パフォーマンス

  • 手続き型: 大規模なデータや複雑なアルゴリズムを扱う場合、手続き型アプローチは直接的なメモリ操作とタイトなループを通じて、高いパフォーマンスを実現することが多いです。
  • 関数型: 不変性と純粋関数により、関数型プログラミングは副作用が少なく、並列処理や遅延評価の最適化が容易になることがあります。しかし、関数の呼び出しや一時オブジェクトの生成が多いため、場合によっては手続き型よりもパフォーマンスが低下することがあります。

可読性

  • 手続き型: 明確なステップとアルゴリズムがコードに直接反映されるため、その動作を追いやすい場合があります。しかし、複雑な条件分岐や状態管理が多いと、コードの可読性は低下します。
  • 関数型: 高レベルの抽象化と宣言的なスタイルにより、コードの意図が読み取りやすくなります。純粋関数の使用は副作用を減らし、コードの予測可能性を高めますが、関数型特有の概念や演算子に慣れていないと、初学者には理解が難しい場合があります。

保守性

  • 手続き型: 変数の状態変化を追跡する必要があるため、大きなコードベースではバグの特定と修正が難しくなることがあります。
  • 関数型: 不変性と純粋関数により、コードの副作用が少なくなり、モジュール間の依存関係が減るため、保守性が高まります。テストがしやすく、機能拡張やリファクタリングが容易になります。

まとめ

この記事では、手続き型プログラミングと関数型プログラミングの違いを、Java、JavaScript、TypeScript、Python のコード例を通じて探りました。各パラダイムが持つ利点と限界を理解することは、より効果的なプログラミングスタイルを選択する上で重要です。

  • 手続き型: 直感的で具体的な操作により、特定のシナリオで高いパフォーマンスを提供しますが、大規模なアプリケーションではコードの複雑性と保守性が問題になることがあります。
  • 関数型: 高い抽象化レベルと不変性により、コードの可読性と保守性が向上しますが、パフォーマンスと学習曲線は考慮する必要があります。

プロジェクトの要件、チームの経験、そして適用される言語の特性を考慮して、最適なアプローチを選択しましょう。

Discussion