はじめに
Rustの特徴としてはメモリ安全性やGCなしで高速といったことがよく挙げられます。それらもRustの利点ではありますが、個人的に魅力を感じる部分の一つに「厳密な型付けをする文化」があると思っています。それはあまり明文化されておらず、個人的に感じているだけなのですが、Rustを使う動機の1つになりうると思うので、ここで宣伝してみます。
ちなみにこの記事のきっかけの1つが
アンサー: なぜTypeScriptの型定義に凝るのか
です。これを読んでいて、「自分はRustの型付け方針が好きなんだなぁ」とあらためて気づきました。
技術的な正確性より「気持ち」多めなのでポエムということで。
「厳密な型付けをする文化」とは
Rustは静的型付き言語なので、ソースコード上の様々なところで型付けが行われます。これ自体は他の静的型付き言語と同様ですが、Rustではどのような型を付けるかということについて、比較的厳密に考える傾向があると思っています。
例えば標準ライブラリでファイルを開く関数の定義は以下のようになっています。
pub fn open<P: AsRef<Path>>(path: P) -> Result<File>
比較のために他のモダンな静的型付き言語としてScalaとGoも見てみましょう。
(ScalaのSource.fromFileはファイルを開いて読み込みまで行うので単純に比較はできませんが、以降の話にはあまり関係ないので気にしないでください)
def fromFile(file: File): BufferedSource
func Open(name string) (*File, error)
ぱっと見でRustの型が一番複雑だと感じるかもしれません。複雑より単純である方が良さそうな気もしますが、Rustの複雑さには理由があります。この3つの関数について、引数型と返り値型をそれぞれ比較しながらその理由を説明していきます。
引数型の比較
まず引数から見ていきましょう。Rustの引数はpath:Pです。これはジェネリクスになっていて、P型の変数pathです。このP型はP: AsRefとあるように、AsRefを満たす何らかの型を示します。AsRefというのはその型がX型に変換可能(厳密にはちょっと違いますがそんな雰囲気です)であることを意味します。以上をまとめるとPath型に変換可能な何らかの型を引数に取る、ということになります。
例えば、文字列型であるStringはPathに変換可能なので、openの引数に直接入れることができます。
(ここも厳密には文字列リテラルはStringではないのですが以下略)
let file = open("file.txt");
これと同じようなことをしているのがScalaです。Scalaでは関数オーバーロードによりいくつかの型に対して同じ関数を提供しています。例えば
def fromFile(uri: URI): BufferedSource
def fromFile(name: String): BufferedSource
のようなオーバーロードが用意されているので、URIや文字列でファイルパスを渡すことが可能になっています。
val file = Source.fromFile("file.txt")
オーバーロードの良い点としては引数の個数を変えられるので、オプショナルな何かを表現しやすいという点が挙げられます。一方Rustはpathとして渡せる可能性のある型をより厳密に表現していると思います。
またPath型も注目に値します。Goはファイルパスとしてstringをとりますが、それと同じように普通の文字列型(つまりString)にしなかったのはなぜでしょうか?
それはファイルパスのエンコーディングがOS依存のためです。Rustでは文字列型はUTF-8と決まっています。一方ファイルパスはWindowsならUTF-16ですし、Linuxなら最近はUTF-8が多いですが、必ずしもそうではありません。そこでRustではファイルパスを表すための文字列型としてOsStringというOS依存の文字列型を用意し、それをラップする形でPath型を用意しています。Path型はOsStringに加えてパス区切り文字や拡張子なども含めて扱える型になっています。
このようにOsStringを別途用意することで、UTF-8文字列であるStringとして使うには別途変換が必要であることが示され、さらにその変換が失敗する可能性も示唆されます。
ScalaのFile型はJava由来の型で、RustのPathと同じような機能を提供しています。
このような複雑な型構成は一見面倒ですが、この型に従っている限り、Windows/Linux/macOSといった主要なプラットフォームでマルチバイト文字を含んだパスを問題なく扱えるようになっています。一方Goの場合、ファイルパスがstringであるというのは非常にシンプルでわかりやすいですが、そこにUTF-8でないファイルパスを格納しようとすると、いろいろと問題が生じそうです。
(Goはあまり詳しくないので、「実は問題ないよ」といった突っ込みがあればお願いします)
戻り値型の比較
長くなってきたので元の関数を再掲します。
pub fn open<P: AsRef<Path>>(path: P) -> Result<File>
def fromFile(file: File): BufferedSource
func Open(name string) (*File, error)
Rustの戻り値型はResult、ScalaはBufferdSource、Goは(*File, error)です。これらはそのまま各言語のエラー処理方針を示しています。
まずRustのResultは「型Xあるいはエラーのどちらか」を表す型です。
正確にはResult<T, E>という型が「T型あるいはエラー型Eのどちらか」を表しており、ここのResultはResult<X, std::io::Error>の型エイリアスになっています。
(Rustはあまり省略を良しとしない言語なので、このような省略は比較的珍しいと感じます)
すなわちResultはFileかIOエラーが返ってくるということを示しています。
このResultはFile型そのものではないので、実際に使うときには以下のようにResultから中身を取り出す必要があります。
let file = open("file.txt");
match file {
Ok(file) => // fileを使う
Err(x) => // エラー処理
}
実際にはいろいろと便利な書き方があるので毎回このような分岐を書くわけではありませんが、このように何らかのエラー処理を強制されるようになっています。
似たような構成になっているのがGoです。(*File, error)とあるようにFileとエラーの有無が一緒に返ってきます。
file, err := os.Open("file.txt")
if err != nil {
// エラー処理
}
こちらはエラー処理をしなかったとしてもfileは問題なく使えますが、このようにerrが返ってくるので何らかの処理をする必要性を感じさせます。
RustとGoは戻り値型の方針がよく似ていますが、エラー型の表現が異なります。Rustにおけるエラー型はstd::io::Errorで、これはIOエラーを表す専用の型です。この型は.kind()メソッドでErrorKindという型のエラー種別を得ることができるようになっています。ErrorKindは以下のようにIOエラーで発生する可能性のあるエラーが列挙されています。
(実際にはopenでは発生する可能性のないエラーまで含まれていますが、それを取り除かなかったのはエラー型の種類が増えすぎるのを避けるためでしょうか)
pub enum ErrorKind {
NotFound,
PermissionDenied,
ConnectionRefused,
ConnectionReset,
ConnectionAborted,
NotConnected,
AddrInUse,
AddrNotAvailable,
BrokenPipe,
AlreadyExists,
WouldBlock,
InvalidInput,
InvalidData,
TimedOut,
WriteZero,
Interrupted,
Other,
UnexpectedEof,
}
一方Goはerror型が返ってきます。これはerrorインターフェースを実装した型であり、汎用のエラー型となっています。もちろん内部的には実際のエラー情報を保持しているので
if os.IsNotExist(err) {
// ファイルが見つからないときのエラー処理
}
のようにエラーを判別することは可能ですが、Rustのように型レベルでどのようなエラーの可能性があるかを示しているわけではありません。
Scalaはこれらとは異なり、例外でエラーを送出します。そのため以下のようにtryでエラーハンドリングする必要がありますが、その必要性について、関数型から知ることはできません。ドキュメントを読んで、どの関数からどのような例外が出るかを把握しておく必要があります。
try {
val file = Source.fromFile("file.txt")
} catch {
// エラー処理
}
このようにRustの戻り値型は3つの中で最も情報量が多く、どのようなエラーが発生する可能性があるかまで型情報に含まれています。
まとめ
ここまでの説明でRustが「いろいろなことに執拗に型付けしようとしている」という雰囲気をつかんでいただけたのではないかと思います。
このような型付けにはメリットとデメリットがあると思います。デメリットとして考えられるのは初見殺しであることでしょう。Rustの学習曲線が急な理由としてライフタイムがよく挙げられますが、個人的にはこのような型付けも大いに影響していると思います。標準ライブラリでよく使われる型をある程度覚えるまでは、ライブラリのリファレンスを見てもさっぱりわからないかもしれません。
ですが型の情報量が豊富なので、慣れてくるとそれだけでだいたいの使い方が分かってきます。わざわざサンプルコードを探したり、英語の説明を読んだりしなくても、いきなり使えてしまうケースも多いです。
また、例外的なケースやエラーパスも型にエンコードされているため、コーディング中に何に気をつけるべきなのか分かりやすいと思います。
一方Goは型付けの方針からして初期の学習が容易な方に振っているな、と感じます。それは必ずしもいいことばかりではなく、コーナーケースを考える必要が生じたときに各プログラマが気をつけないといけない、というトレードオフが発生しているのかもしれません。
このあたり昔読んだJoel On Softwareの「漏れのある抽象化」という話を思い出します。(簡単に言うとどんなに頑張って抽象化してもどこかしらうまく抽象化できない「漏れ」が発生する、という話です)
つまりRustは過度な抽象化をせず内部の複雑さをそのまま型にエンコードして露出させるのに対して、Goは使いやすい抽象化を提供するけどどうしても漏れてしまう、ということではないでしょうか。
実のところRustの抽象化も決して完全なわけではありませんが(エッジケースでの破綻は当然あると思います)主要な言語の中ではかなり少なくなるような努力が見られる、という印象です。
Scalaはそれらの中間的な立ち位置かな、と思います。ScalaはJavaとの相互運用性も重視しているので、そのあたりも影響しているかもしれません。
このような型付けの方針は言語機能だけで決まるわけではありません。RustでもGoのような型付けを採用することもできたはずです。
なので、この型付けは文化なのだと思います。標準ライブラリがこのような型付けを採用しているので、サードパーティのライブラリも自然と似たような型付けを採用することになります。
自分はこのような厳密性がとても好きですが、「こんな細かいことは気にせずに快適にコーディングしたい」という方も当然いるでしょう。実際Rustを試してみたけど合わなかった人の話として、型が複雑すぎる、というのも聞いたことがあります。それはどちらが正しいといった話ではなく、趣味趣向の問題なのだと思います。なので、この話に共感された方にはぜひRustを試してみてほしいと思います。Rustの高速性や安全性が必ずしも必要でない分野であっても、手になじむ道具として使えるかもしれません。