今回は、いよいよ関数型言語・Scalaの関数定義についてみていきます!
Scalaをはじめとする関数型言語でもよく使われる「高階関数」「カリー化」「再帰関数」についても触れていきます。
関数型言語に興味のある人はぜひ読んでみてください😎
関数定義
Scalaでは、関数を以下の構文で定義することができます。
def <関数名>(<引数>: <引数の型>): <返り値の型> = <評価する式>
例えば、名前と苗字、年齢を引数にとってフォーマットして返すformatProfile関数は以下のように定義できます。
def formatProfile(familyName: String, firstName: String, age: Int): String =
familyName + " " + firstName + "(" + age + ")"
println(formatProfile("山田", "太郎", 30)) // 山田 太郎(30)
引数
Scalaで関数を定義する際には、通常の引数指定の他に「デフォルト引数」と「名前付き引数」が定義できます。
デフォルト引数
関数の引数にはデフォルト引数を指定することが可能。
呼び出し時に省略した場合には、デフォルト引数の値が渡された状態で関数が評価されます。
def saySomething(name: String, greet: String = "Hello"): Unit = {
println(s"${greet}, I'm ${name}")
}
saySomething("John") // Hello I'm John
saySomething("John", "GoodBye!") // GoodBye! I'm John
名前付き引数
Scalaでは関数を実行する際に、パラメータ名を指定した状態で呼び出すことができます。
この機能自体はよくあるものなのですが、特筆すべきは名前付き引数用の構文などはなく、通常の関数定義と同じ記法でこの機能が実装されているというところ。
def greet(lastName: String, firstName: String): Unit = {
println(s"${lastName} ${firstName} 探偵さ")
}
greet("江戸川", "コナン") // 江戸川 コナン 探偵さ
greet(firstName="コナン", lastName="江戸川") // 江戸川 コナン 探偵さ
greet(lastName="江戸川", firstName="コナン") // 江戸川 コナン 探偵さ
無名関数
関数の型
Scalaの関数は、Function0、Function1…Function[N]という組み込みのクラスが存在します。
このクラスは最大22の引数をとるFunction22まで定義されており、VSCodeなどで確認すると以下の画像のようにずらずらと似たようなクラスが候補として表示されます。
Scalaにおいて、関数は「第一級オブジェクト(First Class Object)」として扱われ、Function[N]を使ってインスタンス化した関数は、変数や引数に代入することができます。
Function[N]クラスをインスタンス化する際には、型引数の指定とapplyメソッドの実装が必要です。
Scalaではapplyメソッドは少し特別な役割を果たすメソッドで、かんたんに言うとクラスを関数のように呼び出せます。
val <関数名> = new Function2[<第1引数の型>, <第2引数の型>, <返り値の型>] {
override def apply(<第1引数>, <第2引数>) = <式本体>
}
val doubler = new Function1[Int, Int] {
override def apply(x: Int) = x * 2
}
println(doubler.apply(10)) // 20
// `.apply` は省略して、関数のように実行できる
println(doubler(10)) // 20
val adder = new Function2[Int, Int, Int] {
override def apply(x: Int, y: Int) = x + y
}
println(adder(10, 20)) // 30
Function[N]の糖衣構文
上で、Function[N]というクラスを使うと、関数を定義できることを紹介しました。
しかしながら、この記法でのコーディングは冗長になりがち。
そこで、Function[N]を使った記法のシンタックスシュガーが存在し、「無名関数」と呼ばれます。
Javascriptのアロー関数に慣れている人にはかなり見慣れた形に見えるかもしれません。
val <関数名> = (<引数>) => <式本体>
val adder = new Function2[Int, Int, Int] {
override def apply(x: Int, y: Int) = x + y
}
val adder2 = (x: Int, y: Int) => x + y
println(adder(10, 20)) // 30
println(adder2(10, 20)) // 30
関数のカリー化と部分適用
カリー化
「カリー化」は関数型言語でよく使われるテクニックのひとつで、その名前は数学者のHaskell Curryに由来するといわれています。
カリー化というテクニックをかんたんに説明すると、「複数の引数をとる関数を、1引数関数の連続した呼び出しに置き換えること」と言えます。
例を見てみましょう。
以下は、3引数の関数を、「引数を一つ取って、2引数の関数を返す」関数に書き換えたものです。
// (Int, Int, Int) => Int 型の関数定義
val adder = (x: Int, y: Int, z: Int) => x + y + z
// Int => Int => Int => Int 型の関数定義
val adder = (x: Int) => (y: Int) => (z: Int) => x + y + z
println(adder(10, 20, 30))
println(adder(10)(20)(30)) // 1引数の関数を3回連続で実行している
部分適用
カリー化とセットで覚えておきたいのが「部分適用」です。
特に難しい概念ではなく、カリー化した関数の一部に値を適用した状態の新しい関数を定義することを指しています。
val adder = (x: Int) => (y: Int) => x + y
val adder10 = adder(10) // adderに10を部分適用
val adder100 = adder(100) // adderに100を部分適用
val result1 = adder10(20) // 30
val result2 = adder100(20) // 120
カリー化した関数の引数に値を部分的に束縛した新しい関数を適用すると、汎用的な関数からより特化した関数に変更することができます。
高階関数
高階関数は「関数を引数にとる」あるいは「関数を返り値とする」特徴を持つ関数のことを指します。
関数を引数にとるというのは、何らかの処理の共通化に役立つ概念で、関数型言語においてはよく使われるテクニックです。
また、mapやfilterなどは関数を引数にとる関数ですが、このようにScalaの組み込みの関数の中でも高階関数の概念は多く取り入れられています。
val list = List(1,2,3,4)
println(list.map { n =>
n + 1
})
// 複数行の式の場合は波括弧({ })で囲うが、単一式なら括弧(( ))でいい
println(list.map((n) => n + 10)) // List(11, 12, 13, 14)
// 引数の関数内では、アンダースコア(_)を使って引数を省略できる
println(list.map(_ + 10)) // List(11, 12, 13, 14)
println(list.filter(_ % 2 == 0)) // List(1, 3)
val toPair = (x: Int) => List(x, x)
println(list.flatMap(toPair)) // List(1,1,2,2,3,3,4,4)
再帰関数
再帰関数とは、関数の処理の中でその関数自身を呼び出しているもののことを指します。
(再帰関数の概要については、こちらに記載したので是非参考にしてみてください!)
再帰関数は、Scalaのような関数型言語ではよく使われるテクニックのひとつです。
例えば、1からnまでの値の総和を得る関数sumは以下のように再帰関数で定義できます。
このままでも再帰関数の定義としては申し分ないのですが、現状の定義のままだと、100000など大きな値を引数にした場合にエラーが発生してしまいます。
これは「スタックオーバーフロー」としてよく知られている再帰関数の典型的なエラーで、再帰関数の呼び出し階数が多すぎることが原因で発生します。
def sum(n: Int): Int = {
if (n <= 0) 0
else n + sum(n - 1)
}
println(sum(5)) // 15
println(sum(100000)) // Stackoverflow
これを、以下のように書き換えると、100000を引数とした場合でもスタックオーバーフローが発生せずに結果を取得することができます。
def sum(n: Int, accum: Int = 0): Int = {
if (n <= 0) accum
else sum(n - 1, n + accum)
}
println(sum(5)) // 15
println(sum(100000)) // 705082704
3行目でのsum関数の呼び出しが、関数呼び出し時の最後の呼び出しになっており、その関数の計算処理ステップの最後になっていることがポイントです。(一つ目の例では、sum関数の呼び出し結果にnを加算している。)
これは「末尾再起最適化」というScalaのコンパイラの機能を使ったことによる成果で、再帰関数による呼び出しが最適化されることによってスタックオーバーフローすることなく関数を実行できるようになります。
再帰関数については少し大きなトピックになるので、また別の記事で紹介したいと思います。
まとめ
- Scalaでは関数定義にデフォルト引数や名前付き引数を利用できる
- Scalaの関数は第一級オブジェクトで変数に格納できる
- 再帰関数は末尾再帰最適化することでより効率化でき、スタックオーバーフロー回避に役立つ
Scalaは関数型とオブジェクト指向を組み合わせたマルチパラダイム言語です。
Javaなどのようにオブジェクト指向的に書くのもよいですが、再帰関数や末尾再帰最適化などのテクニックを身につけることで、Scalaのプログラミングがより奥深いものに、より効率的なものになりますよ!
コメント