catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

Scalaのお勉強

お久しぶりですです。かたりぃなです。 今回は関数型プログラミングのお勉強としてScalaをやってみたので、メモとして残したいと思います。

なぜ関数型プログラミング

GitHubCopilotなどのAIを使っていろいろと試してみています。 試していてなんとなく「AIさんも人間も理解しやすいプログラミング言語」をと思ったのがきっかけです。

色々試してみたところ、自分がAIと強調してプログラミングするなら、以下のような言語のほうが使いやすいなと感じました。

それぞれ簡単に説明してみます。

型システム

AIにコードを書いてもらう場合、それを人間が理解しやすいことが重要です。理解するのに手間がかかっていては自分が書いたほうが早いですから。。。

ALGOL系の言語で強い静的型付け言語の有名な例はRust,C/C++,C#などですね。 関数型ではScalaやF#,OCamlあたりもそうでしょうか。

AIによってソースコードを生成した場合、その意味を理解するには型が大きな助けになります。 弱い動的型付け言語の場合は、これをするのが少々難しいなと感じる場面がありました。

プログラミングパラダイム

正直どちのパラダイムでもいいかなと思うのですが、AIによるテストコードの生成は関数型のほうが一歩秀でてる気がしました。 というのも、副作用の排除やモジュールを細かく分割する設計思想とそれを支援する言語の機能がとても優れているためです。

オブジェクト指向で記述されたコードのテストを記述するときに必ずと言っていいほど議論にあがる

  • privateメソッドはテストすべきか否か
  • クラスの内部状態のテストはどこまで実施すべきか?

などの問題も、関数型プログラミングにおいては設計段階からおおむね排除できるというメリットを感じました。 もちろんオブジェクト指向にも大きなメリットがあって、直観的に理解しやすいという多大なるメリットがあります。 こればかりはどちらか一方のパラダイムが良いとするのではなく、状況によって使い分けていけばいいだろうと思います。

実行方式

インタプリタ方式の欠点は、動かしてみるまでそれが正しいかどうかわからないという点です。 そのためのテストだという話でもあるのですが、

それであれば、コンパイラが面倒を見てくれる言語のほうがありがたいです。 これは型システムと組み合わせると力を発揮してくれるもので、論理的な誤りをコンパイラが検出できるということです。 整数型しか受け付けない関数に文字列型を与えるなど、コンパイル段階で誤りに気づいて修正を行うことができます。 コンパイラでライフタイムも管理することでメモリ管理の問題を解決しようという思想がRustであると理解しています。

Scalaの概要

というわけで、これらの条件を満たす関数型言語として、環境も準備しやすいScalaをやってみました。 機能をざっとなめてみただけなので、たいしたものはありませんが、自分がどこまで理解できているかをアウトプットするのが大事だと思っています。

関数型言語として以下のような機能を備えているとのことなので、試してみました。

  • 高階関数
    • 関数を引数にとる関数
    • 関数を返す関数
  • 引数を変更する
    • カリー化
    • 部分適用
  • ジェネリクス
  • 関数合成
    • andThen
    • compose
  • 型クラスと暗黙のパラメータ
  • ケースクラス
  • パターンマッチ

それぞれ順にみていくことにします。 私のバックグランドとしてC/C++が母国語なので、それらに類する言語での類推で理解した内容を表現していきます。

高階関数

高階関数は関数を受け渡しできる関数です。関数型言語の説明で出てくる第一級関数とは?みたいな難しい話は置いといて、「引数に関数を渡せる」「戻り値で関数が返される」とでも思っておけばいいでしょう。

まずは関数を引数にとる関数です。 applyFnは第1引数に関数を、第2引数に配列をとり、配列の各要素に第1引数の関数を適応していきます。 コールバック関数ですね。

    // 関数を引数にとる関数
    def applyFn(f: Int => Int, x: Array[Int]): Array[Int] = {
        x.map(f)
    }

    // 関数を定義
    val double = (x: Int) => x * 2

    // 高階関数を呼び出す
    println("applyfn test")
    val result = applyFn(double, Array(1, 2, 3))
    println(result.mkString(","))

もう1つの高階関数は、関数を返す関数です。 整数値を与えて関数を呼び出すと、整数を受け取り整数を返す関数が生成されます。この関数は生成時に与えられた値を乗算する関数です。 関数オブジェクトを返す関数といったところでしょうか。

    // 関数を返す高階関数
    def CreateMultiplier(factor:Int) : Int => Int = {
        (x:Int) => x * factor
    }

    println("createMultiplier 3 test")
    val triple = CreateMultiplier(3)
    println(triple(10))

    println("createMultiplier 4 test")
    val quadruple = CreateMultiplier(4)
    println(quadruple(10))

考察: c++ではなかなかこういった操作がしづらいです。というのも、クラスのメソッドを関数は呼び出し規約(thiscall)が異なるため、こういった関数に渡すにはstd::bind()などを使って第一引数にthisポインタを束縛するなどのテクニックが必要になったり、関数を返す場合でもラムダでキャプチャしたローカル変数などはどうなるか等の注意が常に必要になるためです。

カリー化と部分適用

カリー化は、複数の引数をもつ関数について、引数を分割して渡せる形の関数にする操作です。 たとえばよくある2引数をとる関数 add(x,y) を通常呼び出すためには add(1,2)のようにするところですが、 この例のようにadd(x)(y)として記述することで引数を1つずつ渡せるようになります。 カリー化した関数の引数を一部分渡す操作は部分適用になる。。。はず。

object CurryFunction{
    def add(x: Int)(y: Int): Int = x+y

    println("curry test")
    val result1 = add(3)(4)
    println(result1)

    val addthree = add(3)(_)
    val result2 = addthree(4)
    println(result2)

    val addfour = (x:Int) => add(x)(4)
    val result3 = addfour(4)
    println(result3)
}

部分適用は複数の引数をとる関数の一部の引数を固定する操作です。 2引数をとる関数add(x,y)の引数の片方を固定することで、 3を足す関数、4を足す関数を生成しています。

object PartialFunction{
    def add(x:Int, y:Int): Int = x+y

    println("partial function test")
    val addThree = add(3, _: Int)
    val result1 = addThree(4)
    println(result1)

    val addFive = add(_: Int, 5)
    val result2 = addFive(4)
    println(result2)

}

このあたりの機能はC++14くらいからのtemplateとかstd::bindを駆使すれば実現できていた気がしますが、もう覚えてないです。 関数を加工するという記述のしやすさはScalaのほうが圧倒的に優れていると思いますので、さすが関数型言語といったところでしょうか。

ジェネリクス

C++でいうテンプレートですね。

使っている記号が <> なのか[]なのかという違いはありますが、本質的にはいわゆるジェネリクスです。

class CustomStack[A]{
    private var elements: List[A] = Nil

    def push(element: A): Unit = {
        elements = element :: elements
    }

    def pop(): Option[A] = {
        elements match {
            case Nil => None
            case head :: tail => {
                elements = tail
                Some(head)
            }
        }
    }

    def peek: Option[A] = elements.headOption
    def isEmpty: Boolean = elements.isEmpty
}

関数合成

いまいちメリットがわかってないのですが、関数型言語ではを合成できます。 加算を行う関数addと乗算を行う関数mulをさまざまな方法で合成しています。

andThencomposeの違いは順序のみのようです。 ALGOL系の言語のような手続き型的に「実行順序が異なる」と解釈するのはあまりよくなくて、 2つの関数add()とmul()を合成した関数、すなわち「合成した関数」として解釈するのが良いようです。 (やっぱり何がうれしいのかわからない)

    def add(x:Int, y:Int): Int = x + y
    def mul(x:Int, y:Int): Int = x * y

    val addAndMul = (x:Int, y:Int) => mul(add(x, y), y)

    // 以下2つは同じ意味になる。
    // andThenを使って関数合成をする
    val addThenMul = (x: Int, y: Int) => ((add _).curried(x) andThen (mul(_, y)))(y)

    // composeを使って関数合成をする
    val mulComposeAdd = (x: Int, y: Int) => ((mul _).curried(y) compose (add(_, x)))(y)

    // 手続き的な記述
    def proceduralAddAndMul(x: Int, y: Int, z: Int): Int = {
        val sum = add(x, y)
        mul(sum, z)
    }

    // 関数型プログラミングの利点を示す例
    def combineFunctions[A, B, C](f: A => B, g: B => C): A => C = f andThen g

    val addThenMulFunction = combineFunctions(add(2, _), (x: Int) => mul(x, 3))
    val result = addThenMulFunction(4) // (2 + 4) * 3 = 18

    // 引数を3つ取る関数を関数合成を使用してvalで定義
    val addThenMulFunction3 = (x: Int, y: Int, z: Int) => {
        val addCurried = (add _).curried(x)
        val addThenMul = combineFunctions(addCurried, (r: Int) => mul(r, z))
        addThenMul(y)
    }

コメントにも記載しましたが、手続き的な記述で合成する場合と、andThenで合成するのとで全く同じ結果が得られるので、何がメリットなのだろう?といった状態です。 おそらくですが、Rustの場合はand_thenではOptionalやResultでラップされた値を返す関数を結合するのがとてもラクになるので、Scalaの場合でもそのあたりのメリットがあるのかなと思ってます。(OptionalとEitherの取り扱いがラクになる的な?)

型クラスと暗黙のパラメータ

難しく記述したポリモーフィズムに見えますが、いわゆる静的ポリモーフィズムです。 C++でいうところのテンプレートの特殊化を使ったパターンが近いでしょうか。 intShowの定義とstringShowの定義によって、型によってパターンマッチして分岐しているイメージです。

trait Show[A]{
    def show(a:A): String
}

object ShowInstances{
    implicit val intShow: Show[Int] = new Show[Int] {
        def show(a: Int): String = s"Int: $a"
    }

    implicit val stringShow: Show[String] = new Show[String] {
        def show(a: String): String = s"String $a"
    }
}

object Show{
    def show[A](a: A)(implicit s:Show[A]): String = s.show(a)
}


object TypeClass{  
    import ShowInstances._
    def printShow[A](a:A)(implicit s:Show[A]): Unit = {
        println(s.show(a))
    }

    printShow(123)
    printShow("abc")
}

import example.TypeClass.ShowInstances._
TypeClass.printShow(123)
TypeClass.printShow("abc")

ケースクラス

タプルっぽい何か

case class Person(
    name: String,
    age: Int
)
val person = Person("Alice", 25)
println(person)
println(person.name)
println(person.age)

パターンマッチ

関数型言語でよくあるやつですね。

  def matchTest(x: Any): String = x match {
    case 1 => "one"
    case "two" => "two"
    case y: Int => s"scala.Int: $y"
    case _ => "many"
  }

感想と今後の展望

関数合成のところでメリットがよくわかりませんでした。 大昔にCの次はC++だということで学び始めて「構造体の中に関数が入って何がうれしいの?」って思ってたのと同じ感覚です。今はわからなくても、きっとどこかで道が開けるのでしょう。

Scalaをざっと触れてみましたが面白い言語だなという感想です。 私はやはり強い静的型付け言語が好きなのだと思います。

まだまだ知らないことばかりですが、AIを使うのが当たり前な世界になっていくことは想像に難くないので、次の武器を磨きつつ、来るべき時代に備えましょう。 火器が主役の戦場に刀や弓で突入するのはあまりにも無謀です。

もしAIを使う未来が来なかったとしても、それまでに学んだことは役に立つはずなので、いろいろ試していきたいと思います。 次の武器はこんな感じで考えてます。状況に応じて武器は持ち替えていけるようにしておきたいですね。

用途 プログラミング言語
サーバーサイド(バックエンド) Scala, Rust
クライアントサイド(フロントエンド) TypeScript,React
スマホ Rust/JS(tauri)
組み込み Rust,C++

それでは今回はこれくらいで。