「闇の魔術に対する防衛術 Advent Calendar 2020」の 10 日目がやってきました。
さてさて, ここまでの講義で様々な闇の魔術とそれに対する防衛術に関して学んできたと思いますが, 闇というのは酸素でもあります。 なにが言いたいのかといえば, 毒でありながらも現在の生命にとっては必要不可欠であるということです。
どんな魔術師も好き好んで闇の魔術を生み出すことはありません(実践を好む魔術師の話であり, 理論を好む魔術師に関してはそうではないかもしれませんが)。 それでも闇の魔術が生まれてしまうのは, ひとえにそれがなければどうしようもない場合が多々あるから, ということになります。
あまりにも身近すぎて普通は闇の魔術とは考えないような例として, 「作用(副作用)」が挙げられます。 聞いたことがありますよね?
なぜ作用が闇の魔術たるか, は調べればいくらでも文献が見つかるでしょうから, この講義では詳しくは述べません。 そして, 現実的には作用なしにプログラミングをすることは不可能です。 基本的な入出力でさえ作用となるからです。
我々にできることは, 作用の影響をなるべく小さくし, 影響範囲の分離を行うことだけです。 しかし, それらが大切と分かってはいても実践派の魔術師はそのあたりをなあなあにしがちなのは否定できないでしょう。
本講義では, 極限まで縛りを与えた状態での魔術の行使を通して作用の分離について学びます。 本講義で書くコード「そのもの」は実践では使わないでしょうが, 「体験」を通して学んでいただけたらと思います。
最も単純な形の純粋でない関数とは
世の中には作用を持たない関数, すなわち純粋な関数と, 作用を持つ関数, すなわち純粋でない関数があります。
純粋な関数は引数のみを使って計算を行い, その結果を返すのみであり, 外部エントロピーへの影響を与えず, また影響を受けません。 対して純粋でない関数は関数外の環境に相互作用をおよぼします。
ところでこのような考え方があります。
すべての純粋でない関数は「最も単純な形の純粋でない関数」と「純粋な関数」の合成にすぎない。
どういうことかわかるでしょうか?
最も単純な形の純粋でない関数……それは引数のない関数のことです。
エントリーポイント, すなわちmain関数は, コマンドライン引数の存在を一時的に無視するとして, 引数のない関数の代表例と言えるでしょう。 ただ, 実行されて作用を引き起こす, それだけの存在です。
ではそれらの合成にすぎないとは? たとえばいわゆるprint関数のことを考えてみましょう。print(s)関数は以下のように動作します。
- 文字列
sを受け取る。- 受け取った文字列
sを画面に表示する。
実はこの関数は以下のように解釈できる, と言っているのです。
- 文字列
sを受け取る。sを使った以下の関数を生成して実行する。
sを画面に表示する。
そして, 関数が関数を返すことができれば, これはさらに以下のように分解できます。
- 文字列を受け取る。
sを使った以下の関数を生成して返す。
sを画面に表示する。
この返された関数を実行すれば, 最初のprint関数と同様に動作します。 そして, 外側は「純粋な関数」であり, 内側は「最も単純な形の純粋でない関数」であることはおわかりかと思います。
さてさて, この大原則に従えば, あらゆる作用を持つ関数(T1, T2, ...) -> Rは, 以下の関数の合成に書き直せることがおわかりかと思います。
- 純粋な関数
(T1, T2, ...) -> Supplier<R> - 最も単純な形の純粋でない関数
Supplier<R> = () -> R
Supplier(供給者)という名前は Java の関数型インタフェースから選びました。 詳細は公式の資料を確認してもらえたらと思いますが, 今回の用途にぴったりです。
発想の転換: エントリーポイントとはすべての計算の「最終結果」である
さきほどmain関数はSupplierの代表例である(実際にはコマンドライン引数が存在するため, コマンドライン引数を受け取ってSupplier<Void>を返す, とするべきですね)と述べました。
エントリーポイントとは, ランタイムが最初に実行する関数ということになります。 そしてmainは様々な純粋でない関数を起動し, 純粋な関数の結果を使って……, また純粋でない関数を起動し……。
ちょっと待ってください。 つまり, 我々がポンとプログラムを実行するのは, 結局の所はmainの起動に過ぎないわけです。 あとは全部mainの仕事です。
もしも, もしもですよ。 純粋でない関数同士, つまり作用と作用を組み合わせて一つにすることができたなら。 そしてそれが純粋な計算であったなら。
私たちはプログラム全体を「たった一つの純粋でない関数」mainの起動だけにすることができるのではないでしょうか?
「私たち自身が純粋でない関数を一度も起動することなく」, 「本物のプログラム」を作ることができるのではないでしょうか!?
できます。
できるのです。
作用を逐一起動する(その指示を送る)のではなく……計算することによって, 私たちはプログラムを書けるのです。
その意味においてはmainはもはやエントリーポイントではなく, プログラムという大きな計算式の最後の計算結果ということになります。
このコペルニクス的転回は, 純粋関数型言語の極北 Haskell などの土台でもあるのですが, けして遠い国のおとぎ話ではなく, Java でさえも実現可能な世界であることを示しましょう。
ぼくがかんがえたさいきょうのSupplier
作用を計算するとは言っても, 公式に用意されている関数型インタフェースSupplierは起動Supplier#get()ができるだけで機能として貧弱極まりありません。 そこでいくつかのデフォルトメソッドおよびスタティックメソッドを追加したオレオレSupplierをインタフェース継承によって作ります。
なにがほしいのかといえば, Supplier.of(x), Supplier#map(f), Supplier#flatMap(f)の 3 種です。 どこかで見覚えがありますね? これはOptionalやStreamに提供されているメソッドです。 Haskell で有名な「モナド」はこれらをいい感じに定義できるデータ構造として知られています。 実際に Haskell の関数 / 演算子との対応表を以下に示します。
| Java | Haskell |
|---|---|
M.of(x) |
return x |
m.map(f) |
f <$> m |
m.flatMap(f) |
m >>= f |
実際, OptionalやStreamに相当するモナドは Haskell にもあります(無論, それそのものではありませんが)ので, Supplierも加えてそれらの対応表も以下に示しておきましょう。
| Java | Haskell |
|---|---|
Optional |
Maybe |
Stream |
List |
Supplier |
IO |
では実装していきます。
Supplier.of(x)
ofは, 最も単純なデータ構造を返すようなメソッドです。
Optionalではnull以外の値を単にOptionalで包んで返します。 Streamでは単一の要素のみが流れるStreamを返します。
Supplierでは, 実際には作用を起こさず, 値をただ供給するようなSupplierがよいでしょう。 そこで以下のように定義します。
static <T> Supplier<T> of(T value) {
return () -> value;
}
Supplier#map(f)
mapは, 通常の値をとって変換する関数をデータ構造に対して適用するメソッドです。
Optionalでは中身が空でなければ中身の値に適用し, 空ならそのままにします。 Streamでは流れる値に対し関数を適用した値を流す, 新たなStreamを返します。
Supplierでは, 供給される値に関数を適用した値を供給する, 新たなSupplierを生成するようにしましょう。 以下のように定義します。
default <U> Supplier<U> map(Function<? super T, ? extends U> mapper) {
return () -> mapper.apply(this.get());
}
Supplier#flatMap(f)
mapだけでは, 複数のデータ構造同士を組み合わせることはできません。
Optionalの例を考えます。 a, bのふたつのOptionalがあったとき, 双方が中身を持てばそれらを足し, どちらか一方でも空なら空を返す処理を書こうとします。 もしmapしかなければこうなってしまいます。
final Optional<Integer> a = Optional.of(2);
final Optional<Integer> b = Optional.of(3);
final Optionel<Integer> sum = a.map(x -> b.map(y -> x + y));
// sum = Optional.of(Optional.of(5))
Optionalが二重になってしまいました。 これは内側のmapがOptionalを返すからです。 そこで, 自分自身を返すような関数に対して使えるflatMapが存在しています。 これは名前のとおりmapしたあと構造をつぶす(flatten)ように見えます。
final Optional<Integer> a = Optional.of(2);
final Optional<Integer> b = Optional.of(3);
final Optionel<Integer> sum = a.flatMap(x -> b.map(y -> x + y));
// sum = Optional.of(5)
実を言うとofとflatMapがあればmapはいらなかったりします。 なぜならこうできるからです。
final Optional<Integer> sum = a.flatMap(x ->
b.flatMap(y ->
Optional.of(x + y)
)
);
// 対称性はあるがやや冗長, お好みで
とはいえ, 利便性の観点からやはりmapは重要ですね。
さて, これをSupplierに実装します。 この場合はmapと似ていますが, 内部もまたSupplierなので, そちらの値を取り出して供給するような新たなSupplierを返すように実装します。
default <U> Supplier<U> flatMap(Function<? super T, Supplier<U>> mapper) {
return () -> mapper.apply(this.get()).get();
}
次は?
基本的にはこれらのみでことは足りますが, 利便性のことを考えて(mapもそうでしたね)以下のようなメソッドも定義しておきます。
Supplier#andThen(s)
定数関数_ -> sに対してflatMapするようなメソッドandThenを定義しておきます。 こういったメソッドはOptionalやStreamでは単に前の値を捨てるだけですので使う機会などないはずですが, Supplierでは作用を起こすことが目的であって値はいらない場合が多々ありますので, こういうのがあると非常に便利です。
default <U> Supplier<U> andThen(Supplier<U> after) {
return () -> {this.get(); return after.get();};
}
// Supplier#andThen(s) = Supplier#flatMap(_ -> s)
Java のオブジェクトではequalsメソッドが==演算子の代わりに使われていますが, セミコロンは演算子である, という視点から見ると(特に Rust という言語はそんな雰囲気があります), ちょうどandThenは;演算子の代わりをなしています(あるいは C 言語のカンマ演算子のほうがそれらしいでしょうか)。 変数定義を伴う;はmapやflatMapということになりますね。
Supplier#andThenOf(x)
andThenの生の値バージョンです。
default <U> Supplier<U> andThenOf(U value) {
return () -> {this.get(); return value;};
}
// Supplier#andThenOf(x) = Supplier#andThen(Optional.of(x))
// = Supplier#flatMap(_ -> Supplier.of(x))
Supplier#toVoid()
型を合わせるために値を捨てたい, つまりSupplier<Void>を返したい場合, andThenOfを使おうとすると, Voidのインスタンスは生成できないためsupplier.andThenOf((Void)null)とせねばならず, 直観的ではありません。
そこで, これも出来合いのメソッドとして用意しておきます。
default Supplier<Void> toVoid() {
return () -> {this.get(); return null;};
}
「純水」関数型 Java の完成だ
オレオレSupplierが完成したことによって, 作用の計算ができるようになりました。完成品は以下の折りたたみにしまっておきます。
これが作用モナドだ
package aquapura.util;
import java.util.function.Function;
@FunctionalInterface
public interface Supplier<T> extends java.util.function.Supplier<T> {
static <T> Supplier<T> of(T value) {
return () -> value;
}
default <U> Supplier<U> map(Function<? super T, ? extends U> mapper) {
return () -> mapper.apply(this.get());
}
default <U> Supplier<U> flatMap(Function<? super T, Supplier<U>> mapper) {
return () -> mapper.apply(this.get()).get();
}
default <U> Supplier<U> andThen(Supplier<U> after) {
return () -> {this.get(); return after.get();};
}
default <U> Supplier<U> andThenOf(U value) {
return () -> {this.get(); return value;};
}
default Supplier<Void> toVoid() {
return () -> {this.get(); return null;};
}
}
というわけで, Java で純粋関数型言語のように作用の分離を行うシステム, 名付けて AquaPura を提案します。
AquaPura のコードでは作用の実行は許容されません(つまりSupplier#getへのアクセス禁止。 Optional#getが禁止されているのと似ていますね)。 できるのは作用の計算のみです。
AquaPura のコードは Teapot と名付けられたクラスによって実行されます。 Teapot がやることはたった一つです――AquaPura のメイン関数によって与えられるSupplierの実行。 Teapot は AquaPura の世界から見るとランタイムであり, Java の世界から見ると(本物の)エントリーポイントです。
Teapot のコードは以下のようになります。
package aquapura;
import main.Main;
public class Teapot {
private Teapot() {}
public static void main(String[] args) {
Main.main(args).get();
}
}
ネイティブな作用は AquaPura の外にあるライブラリ的なコード, 通称 Leaves から提供されることになります。 Leaves から必要な作用が提供されれば, 理論上は AquaPura の世界のコードのみを書くことでプログラミングができるようになるわけです。
作用の基本は IO, Haskell もそう言っている
まず, Leaves の例として, もっとも基本的な作用ともいえる IO を書きます。
まあこんな感じですね。
package aquapura.leaves;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintStream;
import aquapura.util.Supplier;
public class IO {
private IO() {}
public static final Supplier<String> scanLine = scanLineFrom(System.in);
public static Supplier<Void> printLine(Object x) {
return printLineTo(System.out, x);
}
public static Supplier<String> scanLineFrom(InputStream stream) {
BufferedReader reader = new BufferedReader(new InputStreamReader(stream));
return () -> {
try{
return reader.readLine();
} catch (IOException e) {
return "";
}
};
}
public static Supplier<Void> printLineTo(PrintStream stream, Object x) {
return () -> {
stream.println(x); return null;
};
}
}
Supplier<String>型の値(そう, AquaPura の世界では作用は値です)であるscanLineは標準入力から一行とってくるという作用です。
(Object) -> Supplier<Void>型の関数であるprintLineはなんらかの Object をとってそれを文字列化したものを標準出力に書き出す作用を返します。
今回はこれらを使って簡単なプログラムを書いてみて, 実際にどのように AquaPura でのプログラミングができるかをお見せします。
最初はやっぱり Hello, world
ということで Hello, world です。
package main;
import aquapura.leaves.IO;
import aquapura.util.Supplier;
public class Main {
private Main() {}
public static Supplier<Void> main(String[] args) {
return IO.printLine("Hello, world");
}
}
はい, 作用はたったひとつなのでとくに難しいこともなく完了しました。 mainは作用を実行するのではなく, 最終的に実行されるべき作用を返す関数なので, returnが必要なことだけ忘れないでくださいね。
複数の作用を組み合わせよう
さんざん作用を計算すると言っておいてこれで終わったら拍子抜けですね。 というわけでもうちょっとだけ複雑なプログラムを書いてみましょう。
- 名前を質問する
- 文字列を入力させる
- 名前が入力されていたら挨拶する, 空文字列だったらキレる
うーん, 初歩的な手続きプログラム。 ではやってみましょう。 こうなります。
package main;
import aquapura.leaves.IO;
import aquapura.util.Supplier;
public class Main {
private Main() {}
public static Supplier<Void> main(String[] args) {
return IO.printLine("名前を教えてね。")
.andThen(IO.scanLine)
.flatMap(name ->
name.isBlank() ?
IO.printLine("名乗らんかいタコ") :
IO.printLine(String.format("ハロー, %sさん!", name))
);
}
}
実行してみると, 確かに想定通り実行されます。 こんないかにも手続き的なプログラムも, たった一つの式(メソッドチェーン)で表現できるなんて面白いと思いませんか?
処理を部分に分けたくなってきただろう? これが荒療治だ
さて, 今回は手続きをそのまま書き下しましたが, よくよく考えてみると, 入力内容によって処理を変えているように見えて, 実際には「表示する内容」が変わっているだけで処理内容は共通化できますね。
そこで, 一時変数nameを規則によって「変換」して, 表示の部分は一本化してしまいましょう。 こうなります。
package main;
import aquapura.leaves.IO;
import aquapura.util.Supplier;
public class Main {
private Main() {}
public static Supplier<Void> main(String[] args) {
return IO.printLine("名前を教えてね。")
.andThen(IO.scanLine)
.map(name ->
name.isBlank() ?
"名乗らんかいタコ" :
String.format("ハロー, %sさん!", name)
)
.flatMap(IO::printLine);
}
}
冗長な部分が外に出て見通しがよくなりましたね! せっかくだからmap先のラムダ式を関数に切り出してみましょう。
package main;
import aquapura.leaves.IO;
import aquapura.util.Supplier;
public class Main {
private Main() {}
public static Supplier<Void> main(String[] args) {
return IO.printLine("名前を教えてね。")
.andThen(IO.scanLine)
.map(Main::aisatsuConvert)
.flatMap(IO::printLine);
}
private static String aisatsuConvert(String name) {
return name.isBlank() ? "名乗らんかいタコ" : String.format("ハロー, %sさん!", name);
}
}
こうしてみると型を見て分かるとおり, 変換部分には作用が関係しないことがわかります。 最初期は手続きの一部だった部分を切り出してしまえば, 作用を持たない細かい処理に分けることができ, テストが容易になるというものです。
FizzBuzz で Finish だ
最後に, 定番のプログラムとして FizzBuzz をやってみましょう。 どこまで読み上げるかは標準入力から得ます。 OptionalやStreamといったモナドも使っていきましょう。
package main;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import aquapura.leaves.IO;
import aquapura.util.Supplier;
public class Main {
private Main() {}
public static Supplier<Void> main(String[] args) {
return IO.printLine("Enter a positive integer.") /* 標準出力に書き込み */
.andThen(IO.scanLine) /* 標準入力から1行読み込み */
.map(x ->
Optional.of(x) /* 失敗しうる処理 - ここを関数に分けるのもありですね */
.filter(Main::isLikeInteger) /* 整数として解釈可能でなければ失敗 */
.map(Integer::parseInt) /* `int`に変換 */
.filter(n -> n > 0) /* 正整数でなければ失敗 */
.map(Main::fizzbuzzUntil) /* FizzBuzzの実行結果に転写 */
.orElse("You entered an illegal value.") /* 大域脱出先 */
)
.flatMap(IO::printLine); /* 標準出力に書き込み */
}
private static boolean isLikeInteger(String s) {
return Pattern.compile("^\\-?[0-9]+$").matcher(s).find();
}
private static String fizzbuzzUntil(int max) {
return Stream.iterate(1, n -> n + 1) /* 無限等差数列の生成 */
.limit(max) /* `max`で打ち切り */
.map(Main::toFizzBuzz) /* FizzBuzz文字列に転写 */
.reduce(Main::concatWithLF).orElse(""); /* 改行つき結合で畳み込み */
}
private static String concatWithLF(String left, String right) {
return left.concat("\n").concat(right);
}
private static String toFizzBuzz(int n) {
return n % 15 == 0 ? "FizzBuzz" :
n % 3 == 0 ? "Fizz" :
n % 5 == 0 ? "Buzz" :
String.valueOf(n);
}
}
まとめ
- AquaPura はそのままプロダクトに使えるようなものではありませんが, AquaPura でちょっとしたプログラムを組んでみることでいくつかの学びが得られます。
- AquaPura の
Supplierのメソッドチェーンは手続き型の模倣のように動作します。map,flatMap,andThenは, いわば;演算子のメソッドによる表現です。 - AquaPura でコーディングしていると, 色んなものを関数 / メソッドに切り出したくなります。 適切な粒度で切り出された関数 / メソッドはテスタビリティに正の影響を与えます。
- 純粋関数型言語に手を出したくなってきただろう? ならない? そう……。