はじめに
さて前回まで、関数型言語を使うプログラマーにとって「ある構成がただの関数ではなく、実は本質的には自然変換である」ケースに気づけることが、関数型言語をより良く使いこなす上で重要になってくるのだという話をしてきました。
えっ、してない?
今日は13日の金曜日だ…奇跡がおきても不思議じゃない…
そんな話です。よろしくお願いいたします。よろしくお願いいたしました。
関手と自然変換
関手 is 何?
まず Vec
や Option
など、ある種のものが「関手」と呼ばれるものであると考えられることから見ていきましょう。まずこれらは一旦「型 T
を受け取って、新しい型 Vec<T>
を返す」関数と考えます。
このとき Vec
が「型 S
から 型 T
への矢印」を「型 Vec<S>
から型 Vec<T>
への矢印」に変換する働きを持っている場合、こいつが関手であると雑に考えることにしましょう。矢印というのはお察しの通り、この場合はただの関数です。
let a: Vec<String> = vec![1,2,3,4,5].iter().map(|i| i.to_string()).collect();
to_string
という「i32
から String
への矢印」が、全体として「Vec<i32>
から Vec<String>
への矢印」に「変わっている」ことに注意しましょう。Option
も大体同じです。
自然変換
以下のような「関数」を考えてみます。
fn vec_to_option_nt<T: Clone>(src: &Vec<T>) -> Option<T> {
src.get(0).map(|x| x.clone())
}
Vec
の先頭要素をあれば返す(なければ None
)だけの簡単な関数です。
fn main() {
let a = vec_to_option_nt(&vec![1, 2, 3, 4, 5]);
println!("{}", a.map(|i| i.to_string()).unwrap_or("None".to_string()));
let a = vec_to_option_nt(&vec!["red", "blue", "green", "yellow", "purple", "red-no-ikiwakareta-ani-gold"]);
println!("{}", a.map(|i| i.to_string()).unwrap_or("None".to_string()));
let a = vec_to_option_nt(&(vec![] as Vec<f32>));
println!("{}", a.map(|i| i.to_string()).unwrap_or("None".to_string()));
}
この関数は型パラメータ T
を通じて
Vec<i32> -> Option<i32>
Vec<char> -> Option<char>
Vec<bool> -> Option<bool>
Vec<String> -> Option<String>
...
Vec<OregaKangaetaSaikyouNoStruct> -> Option<OregaKangaetaSaikyouNoStruct>
への変換を全部同時に引き起こしていることがわかります。つまり「関手 Vec
を関手 Option
」に変換していると考えて良いということになります。これが自然変換です。
何だただのジェネリックな関数か...と思われるかもしれません。たしかに一面ではそうですが、「自然変換」であるためには単にジェネリックであるだけでは足りません。
自然変換は、任意の「型 S
から 型 T
への矢印」(つまり関数)と交換可能であるという性質を持っています。(今回は雑に考えているので、任意と書きましたが、上記の実装では Clone
なものに限られます。)
意味がわかりませんね。わからないので、以下のコードを見てみましょう。
use std::time::Instant;
fn vec_to_option_nt<T: Clone>(src: &Vec<T>) -> Option<T> {
src.get(0).map(|x| x.clone())
}
fn main() {
let target = (0..10000000).collect::<Vec<i32>>();
let int_to_bool_fn = |i: i32| -> bool { i % 2 == 0 };
let time = Instant::now();
let result1 = vec_to_option_nt(&target).map(int_to_bool_fn);
println!("{}", time.elapsed().as_millis());
let time = Instant::now();
let result2 = vec_to_option_nt(&target.clone().into_iter().map(int_to_bool_fn).collect());
println!("{}", time.elapsed().as_millis());
assert_eq!(result1, result2);
}
vec_to_option_nt
をしてから int_to_bool_fn
をしても、int_to_bool_fn
をしてから vec_to_option_nt
をしても結果が変わっていないことがわかると思います。
結果が同じなので、多分実装はどちらでも良いんじゃないかなと思います。多分ですが。
この性質は「関数がジェネリックに書ける」ことだけではどう頑張っても導出されないので、「自然変換になっているか」はプログラマーが明確に意識する必要がありそうです。
また、ある構成が本質的には自然変換であることに気づけば、既存の自然変換を「合成」することで(普通の意味で)自然で、スマートな実装が得られるかもしれません。もちろんこの場合、ジェネリックなのはおまけでしかなく、自然変換であること is 本質。
さらに「これ…関数というよりは、本質的には自然変換じゃないですかねぇ…(ドヤァ)」とドヤ顔できますね。
おわりに
いかがでしたか?
関数型言語を使うプログラマーにとって「ある構成がただの関数ではなく、実は本質的には自然変換である」ケースに気づけることが、関数型言語をより良く使いこなす上で重要になってくるのだという話をしてきました。
もともと Scala で書いていたのですが Rust で書き直してみたところ、とても大変なことになりました。
「Rustは学習コストが高い」そう考えている人も多いかと思います。ですが、そういった人たちにも「やっぱりRustは学習コストが高い」ということが伝われば幸いです。