はじめに
Rustの特徴としてはメモリ安全性やGCなしで高速といったことがよく挙げられます。それらもRustの利点ではありますが、個人的に魅力を感じる部分の一つに「厳密な型付けをする文化」があると思っています。それはあまり明文化されておらず、個人的に感じているだけなのですが、Rustを使う動機の1つになりうると思うので、ここで宣伝してみます。
ちなみにこの記事のきっかけの1つが
です。これを読んでいて、「自分は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<Path>
とあるように、AsRef<Path>
を満たす何らかの型を示します。AsRef<X>
というのはその型が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<File>
、ScalaはBufferdSource
、Goは(*File, error)
です。これらはそのまま各言語のエラー処理方針を示しています。
まずRustのResult<X>
は「型X
あるいはエラーのどちらか」を表す型です。
正確にはResult<T, E>
という型が「T
型あるいはエラー型E
のどちらか」を表しており、ここのResult<X>
はResult<X, std::io::Error>
の型エイリアスになっています。
(Rustはあまり省略を良しとしない言語なので、このような省略は比較的珍しいと感じます)
すなわちResult<File>
はFile
かIOエラーが返ってくるということを示しています。
このResult<File>
は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の高速性や安全性が必ずしも必要でない分野であっても、手になじむ道具として使えるかもしれません。