関数型はプログラミングスタイル

2021/11/28に公開約6,000字

「関数型プログラミングは関数型言語じゃないとできないんでしょ?」という質問をたまに受けます。答えは「いいえ」です。もちろん、言語のサポートはあれば越したことはないです。

そもそも命令型及び関数型はプログラミングスタイルです。そして、命令型と関数型の間は0/1ではなく、グラデーションがあります。

なので、関数型プログラミングは関数型言語以外でも使えますし、プログラムをよい設計へ導く考え方ですよ、というのがこの記事の主張です。コード例も交えて説明してみます。

関数型へのアプローチ

  • ロジックを書くとき
    • 可変の変数(var)を使わず、不変の変数(val)を使う
    • 可変のオブジェクト(mutable)を使わず、不変のオブジェクト(immutable)を使う
    • voidやUnitなどの戻り値のない関数は使わず、戻り値を返す(高階)関数を使う
  • 関数を定義するとき
    • 参照透明な関数を定義する
      • 必ず意味のある戻り値を返す(void, Unitは使わない)
      • 関数は数学的であり、戻り値が引数から自明であること
      • 入出力(I/O)を当該処理から分離する
    • クラスのメソッドを定義する際も同様の考え方に従う

可変を使うとき

原則的に不変ですが、以下の場合は可変を許可します。

  • オブジェクトの生成や削除や更新が、高コストな場合
  • 分散環境の都合や他の技術的理由から共有を見合わせる場合

インスタンスを共有しない場合でも、状態管理が簡単になるので基本的に不変したほうがよいです。合理的な理由があれば可変にしてもよいですが、可変にしたらインスタンスを共有しないで局所的に利用したほうがよいです(やむを得ず共有するなら複製を作って相手に渡しますが、複製であっても状態の扱いが神経衰弱なので避けましょう)。

例えば、JavaのStringの更新が頻繁で高コストであれば、StringBuilderを使います。ただしStringBuilderのインスタンスは可変なので共有できません。利用できるのは特定の狭いスコープ内だけです。インスタンスを共有したいならStringBuilder#buildを呼び出して得たStringの不変のインスタンスを共有しましょう。

副作用の局所化

じゃぁ入出力(I/O)しないのか!と誤解される方がたまにいますが、そういうわけではありません。どこかのモジュールで行う必要があります。あくまで副作用を扱わない純粋なモジュールと副作用を扱う純粋ではないモジュールを分けて、副作用を局所化するという意図になります。純粋なモジュールでは上記のアプローチを徹底します。(Haskellのプログラムは純粋ですが、HaskellのプログラムとHaskell自体の関係もこれと本質的に変わりません)

関数型の効能

  • 宣言的で意図が理解しやすい
  • 挙動が安定し結果が予測可能となる
  • プログラムを改修した際、不具合を起こしにくい
  • 副作用が分離されているため、テストがしやすい

これらはプログラムのよい設計に求められることだと考えます。

なぜ副作用と作用を分離するのか

Scalaの設計者である、オーダースキー先生の言葉を借りましょう。

役に立つプログラムというものは、どれも何らかの形で副作用を持つことになるだろう。そうでなければ、プログラムの外の世界に値を提供できなくなってしまう。

しかし、副作用を持たないメソッドへの指向を持つことで、結果として副作用を持つコードを最小化したプログラムが設計しやすくなる。このアプローチには、プログラムをテストしやすくなるというメリットがある。

Scalaスケーラブルプログラミング 第4版

コード例

お好みの言語(Java,Scala,Kotlin,Go,Rust)の例を見てください。(1)→(2)→(3)→(4)の順に関数型スタイルに傾倒します。関数型のプログラミングスタイルは関数型言語だけのものじゃないことが分かると思います。言わずもがな、テストがしやすいは(4)です。

僕は(4)のプログラミングスタイルを積極的に採用しています。

Java

package example

import java.util.List;

public class Main {

    public static void main(String[] args) {
        new Main().run();
    }

    // (1) 命令型スタイル
    void printArgs1(List<String> args) {
        var i = 0;
        while (i < args.size()) {
            System.out.println(args.get(i));
            i += 1;
        }
    }

    // (2) 変数を削除したスタイル。まだ副作用はある
    void printArgs2(List<String> args) {
        for (String arg : args) {
            System.out.println(arg);
        }
    }

    // (3) forEachを使うスタイル。まだ副作用はある
    void printArgs3(List<String> args) {
        args.forEach(System.out::println);
    }

    // (4) 変数も副作用もない純粋関数型スタイル
    String formattedArgs(List<String> args) {
        return String.join("\n", args);
    }
    
    void run() {
        var args = List.of("Hello", "World");
        printArgs1(args);
        printArgs2(args);
        printArgs3(args);
        System.out.println(formattedArgs(args));
    }
}

Scala

package example

// (1) 命令型スタイル
def printArgs1(args: Seq[String]): Unit = {
  var i = 0
  while (i < args.length) {
    println(args(i))
    i += 1
  }
}

// (2) 変数を削除したスタイル。まだ副作用はある
def printArgs2(args: Seq[String]): Unit =
  for (arg <- args) {
    println(arg)
  }

// (3) foreachを使うスタイル。まだ副作用はある
def printArgs3(args: Seq[String]): Unit =
  args.foreach(println)

// (4) 変数も副作用もない純粋関数型スタイル
def formattedArgs(args: Seq[String]): String =
  // なんという簡潔さ...
  args.mkString("\n")

@main def helloWorld() = {
  val args = Seq("Hello", "World")
  printArgs1(args)
  printArgs2(args)
  printArgs3(args)
  println(formattedArgs(args))
}

Kotlin

package example

// (1) 命令型スタイル
fun printArgs1(args: List<String>) {
    var i = 0
    while (i < args.size) {
        println(args[i])
        i++
    }
}

// (2) 変数を削除したスタイル。まだ副作用はある
fun printArgs2(args: List<String>) {
    for (arg in args) {
        println(arg)
    }
}

// (3) foreachを使うスタイル。まだ副作用はある
fun printArgs3(args: List<String>) {
    args.forEach { println(it) }
}

// (4) 変数も副作用もない純粋関数型スタイル
fun formattedArgs(args: List<String>): String {
    return args.joinToString(separator = "\n")
}

fun main(args: Array<String>) {
    val args = listOf("Hello", "World")
    printArgs1(args)
    printArgs2(args)
    printArgs3(args)
    println(formattedArgs(args))
}

Go

// val/letのような機能はないが、変数の値を上書きしないルールで
// カバーすることになるかと。Javaと似た運用になりそう。
package main

import (
	"fmt"
	"strings"
)

// (1) 命令型スタイル
func printArgs1(args []string) {
	i := 0
	for i < len(args) {
		fmt.Println(args[i])
		i += 1
	}
}

// (2) 変数を削除したスタイル。まだ副作用はある
func printArgs2(args []string) {
	for _, arg := range args {
		fmt.Println(arg)
	}
}

// (4) 変数も副作用もない純粋関数型スタイル
func formattedArgs(args []string) string {
	return strings.Join(args, "\n")
}

func main() {
	args := []string{"Hello", "World"}
	printArgs1(args)
	printArgs2(args)
	fmt.Println(formattedArgs(args))
}

Rust

// 雰囲気はScalaと似ている

// (1) 命令型スタイル
fn print_args_1(args: &[&str]) {
  let mut i = 0;
  while i < args.len() {
    println!("{}", args[i]);
    i += 1;
  }
}

// (2) 変数を削除したスタイル。まだ副作用はある
fn print_args_2(args: &[&str]) {
  for arg in args {
    println!("{}", arg);
  }
}

// (3) foreachを使うスタイル。まだ副作用はある
fn print_args_3(args: &[&str]) {
  args.iter().for_each(|arg| println!("{}", arg));
}

// (4) 変数も副作用もない純粋関数型スタイル
fn formatted_args(args: &[&str]) -> String {
  args.join("\n")
}

fn main() {
  let args = &["Hello", "World"];
  print_args_1(args);
  print_args_2(args);
  print_args_3(args);
  println!("{}", formatted_args(args));
}

追記:

Javaの(4)でreduceを使うのではなくString#joinを使えばよいことに気付きましたので、修正しました。

まとめ

コード例をみて「なんだそういうことか、何気なく日頃から意識しています」という方もいらっしゃると思います。堅牢なコードを書く人は関数型のエッセンスを暗黙的に使っていると思っています。意識的に使えるようになれば、設計に対してよりよいのではないでしょうか。

そして、関数型のプログラミングスタイルをプログラミング言語がサポートするかどうかの違いがありますが、今回示したシンプルな用途ではあれば、クロスパラダイムな今どきの言語では大差がなく自然に記述できると思います。

一方で、言語ごとに大差がでてしまう、関数型の他の要素としては以下があります。

  • 高階関数(map,filter,foldなど)を使うコレクションの操作
  • モナドを使うプログラミングスタイル

これは機会があれば別記事でまとめます。

関連資料

Discussion

ログインするとコメントできます