Rustは多くの言語に影響を受けており、C++のほか、OCamlやHaskellといった関数型言語の影響も受けています (参考: The Rust Reference - Influences)。
私はC++からRustに入りましたが、関数型言語を使った経験はありません。せっかくRustを学んだのだから、その影響元である関数型言語についても学んでみたいと思いました。
関数型言語を学ぶならHaskellだろうということで、以下の書籍を参考に触れてみました。その感想を記します。
参考書: プログラミングHaskell 第2版 (出版: ラムダノート)
この記事について
記事の趣旨は「Rustを学んだ後にHaskellを学んだ人がどのような感想を抱いたか」という個人的な記録です。ところどころで「Haskellではこう書く」といった説明を入れますが、Haskellの解説はこの記事の趣旨ではないので、その点はご了承ください。
記事のタイトルは「Rust使用者が~」とありますが、記事内では比較の際にRustのほかTypeScriptを使うことがあります。これは、TypeScriptの方が比較用のコードを示しやすい場面があるためです。
Haskellに触れる前の認識
Haskellに対し、以下のような認識を持っていました。
-
難しそう
- これは学んだ後の今でもそう思います。
-
カリー化について
- 「引数を1つだけ取る関数にすること」というのは知っていた。
- カリー化がどう嬉しいのかは分かっていなかった。クロージャーを使って部分適用するのとどう違うのか?は分かっていなかった。
-
モナドについて
- 「値を包むコンテナのようなもの」という認識を持っていた。
-
Maybe
モナドやEither
モナドという名前は聞いたことがあり、これらがRustのOption
やResult
に相当することは知っていた。 - HaskellにはIOモナドやStateモナドというものもあるらしい、ということは知っていた。
- Functor, Applicative という言葉は知らなかった。
以下、Haskellを学んでみての感想です。
文法
最初に感じたのは文法の見慣れなさです。例えば
add x y = x + y
a = add 1 2
という書き方にみられるような
- 左辺に関数名と引数を記述する
- 関数を
add 1 2
のように適用する
といったスタイルは、自分が知る他の言語と大きく異なるものです。そういうものだと分かれば理解に困ることはないのですが、人によっては抵抗感を感じるかもしれないと思いました。
カリー化
Haskellでは関数は自動的にカリー化されます。例えば
add x y = x + y
a = add 1 2
のように書いた場合、 add 1
は「渡された値に1を加える関数」を返し、それに2を適用したものがa
に入るということです。
-- add: 「渡された引数にxを足す関数」を作る関数
-- add x: 渡された引数にxを足す関数
add x y = x + y
-- a = add 1 2 は以下のように分解できる
add_one = add 1
a = add_one 2
Haskellでは関数は引数を1つだけ取り、必ず値を返します。引数を取らない関数や戻り値を返さない関数 (C/C++でいう void
を返す関数) はありません。そして、複数の引数をとるように見える関数は、カリー化によって実現されています。これは引数が3つや4つに増えても同じです。
JavaScript/TypeScript系の記事のコメントで「カリー化のメリットが分からない」というものを見たことがあるのですが、カリー化の恩恵は関数型言語でないと享受しづらいのかもしれないと思いました。
Haskellでは、以下のような書き方が自然にできます。
add x y = x + y
a = map (add 1) [1, 2, 3] -- a: [2, 3, 4]
クロージャーを使う場合、クロージャーの引数としてx
などの名前が必要にあります。下記はTypeScriptの例です。
function add(x: number, y: number): number {
return x + y;
}
const a = [1, 2, 3].map(x => add(x, 1));
TypeScriptでも以下のように書けばカリー化できます。
const add = (x: number) => (y: number) => x + y;
const a = [1, 2, 3].map(add(1));
しかし、この書き方だと引数を2つ渡したい場合に以下のような呼び出しになり、やや不自然に感じます。
const b = add(1)(2); // add(1, 2) の方が自然
関数の定義も function add(x: number, y: number)
と書いた方が素直ですし、なぜわざわざ const add = (x: number) => (y: number) => x + y;
と書くの?と言われそうです。
Haskellでは add 1 2
のように呼べば引数2つの足し算になるし、add 1
のように途中で止めれば関数になるので、不格好さがありません。
add x y = x + y
a = map (add 1) [1, 2, 3]
b = add 1 2
「カリー化が自動的に行われる」というのはこういうことか、と思いました。
前置、中置、セクション
先ほどは例示のためにadd
関数を定義しましたが、実際は+
演算子で十分です。
a = 1 + 2
Haskellでは関数や演算子の前置、中置を簡単に変換できます。
add x y = x + y
a = add 1 2 -- 関数 add は前置が基本
b = 1 `add` 2 -- バッククォートで囲めば中置になる
c = 1 + 2 -- 演算子 + は中置が基本
d = (+) 1 2 -- 丸括弧で囲めば関数になり、前置になる
また、上記の (+) 1
はセクションという記法を使って (+1)
と書けます。これを使うと「リストの各要素に1を足す」は以下のように書けます。
a = map (+1) [1, 2, 3]
この書き方はこれ以上ないくらいシンプルです。これまでRustのイテレーターやPythonの内包表記に対して「宣言的で分かりやすい」と思っていましたが、関数型言語だとここまで簡潔になるものか、と思わされました。
newtype, data
Rustでは struct UserId(i32);
のように、既存の型をタプル構造体として包むことをnew type idiomと呼びますが、Haskellにはnewtype
という直接的なキーワードがあります。New typeという表現は一般的な語なので、必ずしもHaskell由来ではないのかもしれませんが、こういう用語もHaskellの影響を受けているのかもしれません。
Haskellのdata
は代数的データ型であり、Rustのstruct
およびenum
に相当します。これはRustのenumに先に触れていると理解しやすいものだと思いました。
型クラス
Rustのトレイトだ!と思いました。
実際にはRustのトレイトとは違うものかと思いますが、Rust学習者にとってはトレイトという理解から入れば分かりやすいと思いました。Haskellの型クラス Eq
, Ord
などはRustにも同じ名前のトレイトがあるので、親しみがあります。Haskellの deriving (Show, Eq)
という書き方も、Rustの #[derive(Debug, PartialEq, Eq)]
の書き方と似ています。
自分のこれまでの知識の中だと、RustのトレイトはC#などの言語にあるインターフェースに近いものだと思っていました。Haskellを知った後では、これは型クラスに近いものだと認識が変わりました。
Monad, Functor, Applicative
Haskellを学んでようやく知ることができました。モナドは概念的なものと思っていたのですが、Haskellだと型クラスとして表現できるものなのですね。
RustにはMonad
というトレイトはありませんし、Result
やOption
がMonad
を実装するといった考え方はしません。それぞれand_then
やmap
といったメソッドを持ちますが、これらは個別に実装されています。そのため、これらがモナド的な振る舞いを持つと考えることはできても、それは概念的なものであり、型として表現されるものではありません。
HaskellではMonad
は型クラスであり、Maybe
やEither
はMonad
のインスタンスです。モナドに対する組み込みの構文 (do記法) はMonad
型クラスに対して提供されており、Maybe
やEither
に個別に実装されるものではありません。
Functor
, Applicative
は知らないものでした。Maybe
やEither
, List
がモナドであるという知識からの推論として「モナドはmap操作ができる」と認識していたのですが、これはFunctorとしての性質のようです。MonadはFunctorの十分条件なので「モナドはmap操作ができる」は真ですが、逆は成り立ちません。
Functor, Applicative, Monadについては以下の記事が分かりやすかったです。
余談ですが、C++だと関数オブジェクトのことをファンクタと呼ぶので、最初はそちらに頭が引っ張られました。
IOモナド, STモナド
純粋関数型言語とはこういうことか、と思わされました。
Haskellは純粋関数型言語であり、副作用の扱いが制限されています。多くの言語では fn add(x: int, y: int) -> int
というシグネチャの関数であっても、その内部で標準出力へ書き込んだり、ファイルにアクセスしたり、グローバル変数を扱ったりすることは可能です。これは、関数のシグネチャからは副作用の有無を知ることができず、同じ引数を与えたとしても異なる結果になり得るということです。HaskellではIO
モナド等によって明示しない限り副作用を扱えず、純粋な関数とそうでない関数は明確に区別されます。
これは関数の純粋性を保つ一方で、制約としては厳しいと感じました。他の言語でコードを書いていて、関数内でログに出力したり、デバッグ目的で一時的にprint文を入れることはあるかと思います。しかし、Haskellの通常の関数ではそれが許容されません。RustのOption
やResult
のように、Maybe
やEither
の考えは他の言語にも取り入れられそうに思いますが、IO
やST
モナドを他の言語に持ち込む (IO
やST
でない文脈での副作用を禁止する) のは拒否反応の方が強そうです。
IO
やST
が必要なのはHaskellが純粋関数型言語だからであり、関数型言語全般に言えることではなさそうです。OCamlやScala, F#といった言語は関数型言語ですが、副作用は許容されています。関数型言語を学ぶならHaskellが良いと聞いたことがありますが、その理由の一つとして、Haskellが純粋関数型言語だからというのがありそうです。
do記法とRustの?演算子
Haskellではdo記法という構文が用意されています。これはモナドに対する継続的な操作を扱うものです。IO
と共に使われることが多そうですが、Maybe
やEither
にも使うことができます。
これはRustの?
演算子に似ていると思いました。例えば以下のような関数を考えます。
-
safediv
: ゼロ割りを防ぐ安全な割り算- 引数
a
,b
を受けとる。b
が0であればNone
を、それ以外であればSome(a / b)
を返す。
- 引数
-
process
: 何かしらの処理- 引数
a
,b
,c
,d
を受け取り、 (a÷b + c÷d) を返す。 - 割り算には
safediv
を使い、ゼロ割が発生したらNone
を返す。
- 引数
Rustで?
演算子を使うと、これは以下のように書けます。
fn safediv(a: i32, b: i32) -> Option<i32> {
match b {
0 => None,
_ => Some(a / b),
}
}
fn process(a: i32, b: i32, c: i32, d: i32) -> Option<i32> {
let x = safediv(a, b)?;
let y = safediv(c, d)?;
Some(x + y)
}
Haskellのdo記法では以下のようになります。
safediv :: Int -> Int -> Maybe Int
safediv _ 0 = Nothing
safediv a b = Just (a `div` b)
process :: Int -> Int -> Int -> Int -> Maybe Int
process a b c d = do
x <- safediv a b
y <- safediv c d
return (x + y)
x <- safediv a b
のところで、Rustの let x = safediv(a, b)?;
と同じようにMaybe
を剥がしています。全く同じものではありませんが、「失敗するかもしれない」などの文脈を剥がしつつ処理を継続させる点で、目的は似ていると思いました。
なお、Rustの?
はOption
とResult
にのみ使えますが、Haskellのdo記法はモナド全般に使えます。例えばList
モナドでは以下のような結果になります。
process :: [Int] -> [Int] -> [Int]
process a b = do
x <- a
y <- b
return (x * y)
-- a: [1, 2, 3, 10, 20, 30, 100, 200, 300]
a = process [1, 10, 100] [1, 2, 3]
異なる種類のモナドに対してdo記法は使えません。これは、Rustの?
でOption
とResult
を混ぜたり、エラー型の異なるResult
を混ぜたりできないのと同じ理屈だと思います。
Haskellでは異なるモナドを扱う際にモナド変換子というものが使えるそうですが、そこまでは学習していないため、ここでは触れません。
Rustのasyncについて
Rustのasync
も「時間のかかる処理」という文脈を持ったモナドのようなものであり、await
はそれを剥がすものと言えそうだと思いました。
use std::time::Duration;
use tokio::time::sleep;
// 1秒かけて足し算を行う関数
async fn add(a: i32, b: i32) -> i32 {
sleep(Duration::from_secs(1)).await;
a + b
}
#[tokio::main]
async fn main() {
let x = add(1, 2).await;
println!("{x}");
}
Rustの?
とawait
は異なる記法であるため、前述した「do記法は異なる種類のモナドを扱えない」といった問題は回避されます。Rustでは「時間がかかる失敗するかもしれない処理」を以下のように書くことができます。
// 1秒かけて安全な割り算を行う関数
async fn safediv(a: i32, b: i32) -> Option<i32> {
sleep(Duration::from_secs(1)).await;
match b {
0 => None,
_ => Some(a / b),
}
}
async fn process(a: i32, b: i32, c: i32, d: i32) -> Option<i32> {
// 「時間がかかる」「失敗するかもしれない」の両方を剥がしている
let x = safediv(a, b).await?;
let y = safediv(c, d).await?;
Some(x + y)
}
Rustのクロージャーについて
Rustの関数はHaskellほどには自由に扱えません。例えば3引数を受け取る関数をカリー化しようとする場合、以下のような書き方が必要になります。
fn add(a: i32) -> impl Fn(i32) -> Box<dyn Fn(i32) -> i32> {
move |b| Box::new(move |c| a + b + c)
}
Haskellでは add a b c = a + b + c
と書けば自動的にカリー化されるため、関数の取り回しの良さは段違いです。
これは、Rustが効率を最重視する言語であるためだと思います。C++でも「std::function
を使うとヒープを使うから、ラムダ式を受け取る関数はtemplate
を使う」といったことを行いますが、Rustもこれと同じようなものです。1段階だけであれば fn add(a: i32) -> impl Fn(i32) -> i32
のように書くことができ、戻り値のクロージャーをスタックに置くことができますが、2段階目以降はBox化する必要があります。
Rustは関数型言語の影響を受けつつも、メモリ効率などに関わる場面では効率の方を重視しており、「関数型プログラミングのスタイルで書けること」よりも優先されるのだと感じました。
その他: Prelude
HaskellではPreludeというモジュールがあり、その中の関数が組み込み関数として扱われます。これ、Rustでも同じですね。
私は音楽が趣味なので「Rustのプレリュードって洒落た名前だな」と思ってました (音楽ではプレリュードは前奏曲という意味です) 。この名前がおそらくHaskell由来だというのは初めて知りました。
これもHaskellへの気付きの一つではありますが、言語の機能や構文に関するものではないため、「その他」という扱いにしています。
おわりに
初めて関数型言語に触れてみました。私が実務で関数型言語を使う可能性は (少なくとも現時点では) ほぼ無いだろうというのが正直なところですが、それでも学ぶ価値はあったと思います。「関数型言語を学ぶとプログラミング観が広がる」といったことを聞きますが、これはその通りだと思いました。
これまで関数型言語に対して「難しそう」という漠然とした印象だけ持っていましたが、今は「ちょっとだけ分かる」くらいにはなれたと思います。始めは分からなくても、書籍を読み返したりコードを写経したりするうちに次第に分かるようになってきたので、良い経験でした。