GyakubaricEffects

Sailer-Fuku, Functional Programming, Post-Apocalypse

プログラミング言語 Koka 試した

おーみーです.突然ですがみなさん未来は好きですか?未来は最高なのでプログラミングとコンピュータがさらなる進歩を遂げることが期待できますが,今回はまさにそんなプログラミング言語の未来を形作るであろう実験的研究用言語 Koka について書きます.Koka でもっともわかりやすい特徴は 代数的エフェクト (algebraic effects) を搭載していることです.最近流行りの,React Hooks や React Suspense に関係しているアレと言われたらピンとくる方もいらっしゃるのではないですか?さあ,一緒に Koka を触ってプログラミング言語の未来を垣間見ましょう!

Koka は Microsoft Research というところで開発されている研究用言語で,「こうか」と読みます.最初はコカの実とかのもじりかと思っていたんですがそうではなく,なんと日本語の「効果」に由来するらしいです.

Koka is a function-oriented language that separates pure values from side-effecting computations (The word ‘kōka’ (or 効果) means “effect” or “effective” in Japanese).
A Tour of Koka (強調は筆者による)

インストール

Koka 処理系のインストールは curl | bash するだけです.

❯ curl -sSL https://github.com/koka-lang/koka/releases/latest/download/install.sh | sh
❯ curl -sSL -o %tmp%\install-koka.bat https://github.com/koka-lang/koka/releases/latest/download/install.bat && %tmp%\install-koka.bat

わたしは Nix を使っているので,システムにインストールせず試せます.shell.nix にこのように書くだけで:

{ pkgs ? import <nixpkgs> { } }: pkgs.mkShell {
  packages = with pkgs;[ koka ];
}

便利.

VS Code のシンタックスハイライトプラグインが公式から提供されているので入れておきましょう.

Hello, world

10 回 hello と言うプログラムを書いてみました.拡張子は .kk です.

// コメントはダブルスラッシュで書く
// さすがにPrism.jsのシンタックスハイライトはないらしく色がつかない
fun main() {
  say-hello(10)
}

fun say-hello( n : int ) {
  var i := 0
  while(fn() { i < n }, fn() {
    println("hello!")
    i := i + 1
  })
}

while の周辺が恐ろしい形をしていますが,まだ気にしないことにします.

Koka 処理系は koka という名前の CLI から起動できます.koka -e ファイル名cargo run とかの「コンパイルして実行」にあたります.

❯ koka -e src/hello.kk
compile: src/hello.kk
loading: std/core
loading: std/core/types
loading: std/core/hnd
check  : src/hello
linking: src_hello
created: .koka/v2.3.2/cc-debug/src_hello

Hello, Koka!

REPL は koka で起動できます.

「Minimal but General」

Koka は「Minimal but General」という設計思想があるらしいです.最小限の機能を応用していろんなことができるという考え方です.具体的にどんなところがかというと,一番わかりやすいのは 普通の言語なら組み込み構文になっているものが関数として実装されている ことでしょう.

さて,ここで 10 回 hello プログラムを再掲:

fun main() {
  say-hello(10)
}

fun say-hello( n : int ) {
  var i := 0
  while(fn() { i < n }, fn() {
    println("hello!")
    i := i + 1
  })
}

パッと見で while(fn(){}, fn(){}) という形になっていることがわかるでしょう.Koka では関数呼び出しはダサい方 (丸括弧がつくほう) を採用しており,この while は関数です.

fn(){} は JavaScript のような見た目をした無名関数です.つまり,この while 部分は JavaScript でいうと

while_(
  function () {
    return i < n;
  },
  function () {
    console.log("hello!");
    i = i + 1;
  },
);

のようにあらわされることになります(実際の JavaScript ではアロー関数やインクリメントを駆使してもっと短く書けるが,Koka との対応性を優先してこのように書いている).

実は,引数のない無名関数 fn(){}{} と略記できます.

fun say-hello( n : int ) {
  var i := 0
  while({ i < n }, {
    println("hello!")
    i := i + 1
  })
}

さらに,{} が引数になっている場合,関数呼び出しの丸括弧は省略できます.

fun say-hello( n : int) {
  var i := 0
  while { i < n } {
    println("hello!")
    i := i + 1
  }
}

だいぶ while らしくなってきましましたね.

ここで「条件部が波括弧 {} で書かれているなんてキモイ!」と思った読者はいるでしょうか?たしかに C 言語での while (expr) {} という形からはずれた Koka は慣れないと少々奇妙に感じるかもしれませんね.が,しかし,よく考えてほしい,while において条件部は複数回評価されうる ということを.普通に関数呼び出しで while(i < 0) となっているんだったら,式 i < 0 は関数に渡される前に 1 回だけ評価されるはずなんです.あなたは C 系言語に慣れきって,一貫性に欠く文法にすっかりなじんでしまって,違和感を覚えなくなってしまったのではありませんか?実際わたしがそうだったのだからそうに違いない.この Koka には正しいと信じる一貫性がある.

最後に,これは好みかもしれないが,{} を使ったブロックはインデントに置き換えることができます.

fun say-hello( n : int )
  var i := 0
  while { i < n }
    println("hello!")
    i := i + 1

while はあくまで関数でしかないのだが,あたかも言語組み込みの構文かのように扱うことができている.素晴らしいですね.

ちゃっかり関数宣言のブロックもインデントに書き換わっているが,逆に言うと関数を

fun main() fn() {
  say-hello(10)
}

とも書ける.先生頭痛が痛いです!

UFCS

以下は公式チュートリアルから引っ張ってきたシーザー暗号をやるプログラムです:

fun encode( s : string, shift : int )
  fun encode-char(c)
    if c < 'a' || c > 'z' then return c
    val base = (c - 'a').int
    val rot = (base + shift) % 26
    (rot.char + 'a')
  s.map(encode-char)

fun caesar( s : string )
  s.encode(3)

fun main()
  "koka is a programming language which has algebraic effects"
    .caesar
    .println

注目すべき点はいくつかある:

  • 関数内で関数定義できる
  • 早期 return がある
  • 不変変数は val

しかし,もっとも注目すべき点は要所要所で登場するドット . です.rot.char だとか (c - 'a').int のような用法を見ると「あ,それぞれの型に型変換のメソッドが定義されていて,引数を取らない場合は () が省略できるのか」というふうに解釈できるんですが,fun encode ( s : string, shift : int ) というように宣言された関数が s.encode(3) と呼ばれています.つまり,Koka では f(r, a, b)r.f(a, b) と書けます.これは dot notation と呼ばれており,D 言語Nim の UFCS (Uniform Function Call Syntax) に相当する.相当するというかまったく同じなんですが D 言語とは独立に開発されているらしい.

It was also one of the first languages to have dot notation (This was independently developed but it turns out the D language has a similar feature (called UFCS) which predates dot-notation). https://koka-lang.github.io/koka/doc/book.html#sec-with

with

with というのは Koka がはじめて導入した (と公式チュートリアルは主張している) 機能です.簡単に言うと

f(引数, fn(x){
  残りの部分
})

with x <- f(引数)
残りの部分

と書けます.なんだか Haskell の do とか OCaml の let* とか,特殊化した例で言うと await とかにも似ており,Monadic な物体に対して map や bind をかますことはもちろんできます:

fun product(a : list<int>, b : list<int>)
  with x <- a.flatmap
  with y <- b.map
  x * y

fun main()
  [1,2,3].product([4,5,6]).show.println

しかしながら Koka の with はこれに限らず,条件を満たした任意の関数に対してそのまま適用できることが便利です.

たとえば受け取った関数を 2 回呼べば残りの部分を 2 回実行できます:

fun twice(f) {
  f()
  f()
}

fun main() {
  with twice
  println("hello!")
}

「残りの部分」はいわゆる継続ってやつ (だと思います).

組み込み関数 finally を使うと後続の処理が終了したときに行う処理が定義できます.Go の defer みたいなやつ.finally はなんか謎の仕組みによって throw で終了した場合も確実に処理が実行されるみたいです.ソースコードを読もうとしたが unsafe の文字列が見えたので逃げてきました.

fun main() {
  with finally { println("finished!") }
  println("processing!")
  throw("oops")
}

エフェクトの型

Koka は関数のもつ作用を追跡します.たとえばこんな感じの作用があります.

// int -> total int
// 数学的な全域関数.純粋関数.
fun square(x : int)
  x * x

// (int, int) -> exn int
// 例外をブン投げる可能性がある.exception.
fun divide(x : int, y : int)
  if y == 0 then throw("divide by zero")
  x / y

// int -> div int
// 停止しない可能性がある.diverge.
fun fact(n : int) : div int
  if n == 0 then 1
  else n * fact (n - 1)

// int -> console int
// コンソールに書き込む可能性がある.
fun log(i : int)
  println(i)
  i

関数の起こしうる作用は型推論がはたらくので,いちいち型を明示する手間はあまりありません.

エフェクトは型引数にできます.以下のコードでは map-maybe に渡す第 2 引数 f の作用がそのまま map-maybe の作用になる.

// 1文字が型引数.30要素タプルを扱うときどうするんすか
fun map-maybe(m : maybe<a>, f : a -> e b) : e maybe<b>
  match m
    Just(v) -> v.f.Just
    Nothing -> Nothing

fun main()
  // maybe<int>
  Just(3).map-maybe(square)
  // div maybe<int>
  Just(10).map-maybe(fact)

エフェクト定義とハンドラ

「ログに出力する」というエフェクトを定義してみた:

// logのインターフェースだけが定義されている
effect logger
  ctl log(value : int) : ()

// (int, int) -> logger int
fun f(x : int, y : int)
  val a = x * x
  log(a)
  return a * y

// loggerをハンドルしてない
fun main()
  f(2, 3).println

これはコンパイルエラーになります.logger をハンドルしていないからです.もっと具体化して言うと,log の具体的な実装が与えられていない.

src/handler.kk(9, 5): error: there are unhandled effects for the main expression
  inferred effect : <console,src/handler/logger>
  unhandled effect: src/handler/logger
  hint            : wrap the main function in a handler

「wrap the main function in a handler」というアドバイスをいただいたものの残念ながら main はハンドラじゃ囲めないので代わりに f を囲んでいきます.

fun main()
  handle ({ f(2,3).println() })
    ctl log(value)
      println(value)

あー,ちょっと待ってくれ,えーっと? handle の後にあるのがエフェクトを起こす可能性があるブロックで,そこで発生した log に詰まった値を log(value) でパターンマッチのようにして分解している.

Algebraic effects のハンドラはこんな感じになりがちだが,「エフェクトを起こす可能性があるブロック」が変な位置にある(実は match 値 パターン と同じ形をしているのだが,値の位置にブロックという長くなりうる物体が置かれるので読みづらいのだと思う.たぶん)おかげか普通に読みづらく algebraic effects の理解を妨げている感がある,と私は勝手に思っています.そこで Koka が誇る新構文 with handler を登場させましょう.

with handler 構文を使うと「エフェクトを起こす可能性があるブロック」を with のように後ろに持ってこれます.こうすると「囲む」感はあまりないです.

fun main()
  with handler
    ctl log(value){ println(value) }
  f(2, 3).println

計算を再開する

さてさて実行結果は~:

4

おや?x * x の結果しかないですね.それはそうで,flog をぶん投げたらそのまま制御がハンドラに移行して帰ってこず,継続である a * yprintln は打ち捨てられてしまっているからです.

これだと,例外のような処理を放棄する仕組みとしては悪くありませんが,ログを出力するという目的にはまったく合致してないです.そもそも algebraic effects とは乱暴に言えば「再開できる例外」なので例としても面白味に欠けます.というわけで,再開,つまり継続を呼び出すことをやっていきます.

継続は resume という特別な名前の関数に詰まっています.log(value : int) : () という定義を思い出し,() を渡すと:

fun main()
  with handler
    ctl log(value)
      println(value)
      resume(())
  f(2, 3).println
4
12

よし.

ハンドラを差し替える

ここで,ログを標準出力の代わりにファイルで書き込みたくなった(標準出力をリダイレクトすればいいのではないかというツッコミはなしでお願いします)とする.そのような場合もハンドラを差し替えれば:

import std/os/file
import std/os/path

// ...

fun main()
  // write-text-fileが投げる作用をハンドリング
  // with handler brk の省略
  with brk throw-exn(err)
    println(err.show)
  with ctl log(value)
    // exnを投げうる
    write-text-file("/dev/stderr".path, value.show)
    resume(())
  f(2, 3).println

いい感じに実装だけを差し替えられます.

状態をつくる

ハンドラ内で共有された状態を作ってみます:

effect counter
  ctl increment() : ()
  ctl decrement() : ()
  ctl cnt() : int

fun print-increment-print()
  cnt().println
  increment()
  cnt().println

fun countdown()
  while { cnt() > -10 }
    decrement()
    cnt().println()

fun root()
  print-increment-print()
  print-increment-print()
  countdown()

fun main()
  var value := 0
  with handler
    fun increment()
      value := value + 1
    fun decrement()
      value := value - 1
    fun cnt()
      value
  root()

fun ハンドラ

ctl の代わりに fun というハンドラ定義があります.これは「エフェクトの引数を受け取って何らかの計算をし,その結果を継続に渡す」を表しています.

with fun foo(引数)
  本体

with ctl foo(引数)
  val f = { 本体 }
  resume(f())

に脱糖されます.

ハンドラを関数に切り出す

ところで,main() にハンドラがベタ書きされているのがあまりよくないと思いませんか?同じようなハンドラを何回も書くようなときがありそうですし,関数として再利用したいですね.

fun handle-state(th)
  var value := 0
  handle (th)
    fun increment()
      value := value + 1
    fun decrement()
      value := value - 1
    fun cnt()
      value

fun main()
  handle-state
    root()

ここで先ほどの fn(){} の省略が効いてくるわけですね.もちろん with を使って書き換えてもよし.

おわりに

実のところ Koka は研究用言語なので標準ライブラリが不足しておりネットワークとかを使ったアプリケーションがまだ書けなさそうなんですが,それでもいい感じの言語機能を搭載しているってことは伝わったのではないでしょうか.

Koka には他にも Perceus という参照カウントに関連した技を使うことでガベージコレクションなしで C コードに変換されるので GC を使うような言語よりも高速に動作する~みたいな研究も突っ込まれており,実装面でも先進的みたいです.C だけでなく JS や Wasm にも変換できるようなので React みたいな UI フレームワークに algebraic effects を足したものを作れるかもしれませんね.