はじめに
前回に引き続き、Rust Design Patternsを翻訳しました(分からないところは DeepL に頼りました)。
今回はデザインパターン以降の部分です。
- FFI の部分はよく分からなかったためスキップしています。
- 翻訳間違いなどあるかもしれません(教えていただきたいです。)
長くなってしまいましたが、この記事には以下の内容が記されています。
以下から本文です。
デザインパターン
デザインパターンは、「ソフトウェア設計において、与えられた文脈の中でよく起こる問題に対する、一般的で再利用可能な解決方法」のことです。デザインパターンはプログラミング言語の文化について説明するのにとてもよい方法です。デザインパターンは非常に言語に依存しています。ある言語ではパターンであっても、別の言語では言語の特性上不必要であったり、機能が不足していて表現できなかったりします。
デザインパターンは使いすぎるとプログラムを余計に複雑にしてしまいます。しかし、プログラミング言語についての中級・上級レベルの知識をシェアするのにとてもよい方法です。
Rust におけるデザインパターン
Rust には非常にたくさんのユニークな機能があります。これらの機能によってクラスに関する問題を取り除くことができ多くの利点をもたらしてくれます。中には Rust ならではのパターンもあります。
YAGNI
YAGNI とは You Aren't Going to Neet It.(それはきっと必要ない)の頭文字をとったものです。コードを書くときに役立つソフトウェア設計の重要な原則です。
これまで書いた中で最高のコードは書かなかったコードだ。
YAGNI をデザインパターンに適用してみると、Rust の機能によって多くのパターンを捨て去ることができるとわかります。例えば、Rust ではトレイトを使うことができるため Strategy パターンが必要ありません。
振る舞いに関するパターン
Wikipediaより:
オブジェクト間の共通のコミュニケーションパターンを定めるデザインパターンのこと。これにより、コミュニケーションをおこなう際の柔軟性を高めることができる。
Command
解説
Command パターンの基本的なアイデアは、アクションを独自のオブジェクトに分離しパラメータとして渡すことです。
動機
一連のアクションやトランザクションがオブジェクトとしてカプセル化されているとします。それらのアクションやコマンドを、ある順番で後で別々に実行したり呼び出したりしたいとします。これらのコマンドは、あるイベントの結果としてトリガーされることもあります。例えば、ボタンを押したときやデータパケットが到着したときです。さらに、これらのコマンドは元に戻せない場合もあります。これは、エディタの操作に役立ちます。実行されたコマンドのログを保存しておけば、システムがクラッシュしたとき、あとで変更をもう一度適用できるかもしれません。
例
2 つのデータベース操作 create table
と add field
を定義します。これらのコマンドにはそれぞれ、元に戻すことのできるコマンドがあります。drop table
と remove field
です。ユーザがデータベースの migration 操作を呼び出したとき各コマンドが定義された順番で実行され、ユーザが rollback 操作を呼び出したときにはコマンドセットが逆順で呼び出されます。
Approach: トレイトオブジェクトを使用する
execute
と rollback
の 2 つの操作を持つコマンドをカプセル化する、共通のトレイトを定義します。すべてのコマンドの構造体はこのトレイトを実装しなければなりません。
pub trait Migration {
fn execute(&self) -> &str;
fn rollback(&self) -> &str;
}
pub struct CreateTable;
impl Migration for CreateTable {
fn execute(&self) -> &str {
"create table"
}
fn rollback(&self) -> &str {
"drop table"
}
}
pub struct AddField;
impl Migration for AddField {
fn execute(&self) -> &str {
"add field"
}
fn rollback(&self) -> &str {
"remove field"
}
}
struct Schema {
commands: Vec<Box<dyn Migration>>,
}
impl Schema {
fn new() -> Self {
Self { commands: vec![] }
}
fn add_migration(&mut self, cmd: Box<dyn Migration>) {
self.commands.push(cmd);
}
fn execute(&self) -> Vec<&str> {
self.commands.iter().map(|cmd| cmd.execute()).collect()
}
fn rollback(&self) -> Vec<&str> {
self.commands
.iter()
.rev() // イテレータの方向を反転させる
.map(|cmd| cmd.rollback())
.collect()
}
}
fn main() {
let mut schema = Schema::new();
let cmd = Box::new(CreateTable);
schema.add_migration(cmd);
let cmd = Box::new(AddField);
schema.add_migration(cmd);
assert_eq!(vec!["create table", "add field"], schema.execute());
assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}
Approach: 関数ポインタを使用する
別のアプローチとして、個々のコマンドを別の関数として作成し、関数ポインタを保存することで、あとから別々に関数を呼び出すことができます。関数ポインタは Fn
、FnMut
、FnOnce
の 3 つのトレイトすべてを実装しているため、関数ポインタの代わりにクロージャを渡して保存することもできます。
type FnPtr = fn() -> String;
struct Command {
execute: FnPtr,
rollback: FnPtr,
}
struct Schema {
commands: Vec<Command>,
}
impl Schema {
fn new() -> Self {
Self { commands: vec![] }
}
fn add_migration(&mut self, execute: FnPtr, rollback: FnPtr) {
self.commands.push(Command { execute, rollback });
}
fn execute(&self) -> Vec<String> {
self.commands.iter().map(|cmd| (cmd.execute)()).collect()
}
fn rollback(&self) -> Vec<String> {
self.commands
.iter()
.rev()
.map(|cmd| (cmd.rollback)())
.collect()
}
}
fn add_field() -> String {
"add field".to_string()
}
fn remove_field() -> String {
"remove field".to_string()
}
fn main() {
let mut schema = Schema::new();
schema.add_migration(|| "create table".to_string(), || "drop table".to_string());
schema.add_migration(add_field, remove_field);
assert_eq!(vec!["create table", "add field"], schema.execute());
assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}
Approach: Fn
トレイトオブジェクトを使用する
最後のアプローチは、共通のコマンドのトレイトを定義する代わりに、Fn
トレイトを実装する各コマンドを個別にベクトルに保存することです。
type Migration<'a> = Box<dyn Fn() -> &'a str>;
struct Schema<'a> {
executes: Vec<Migration<'a>>,
rollbacks: Vec<Migration<'a>>,
}
impl<'a> Schema<'a> {
fn new() -> Self {
Self {
executes: vec![],
rollbacks: vec![],
}
}
fn add_migration<E, R>(&mut self, execute: E, rollback: R)
where
E: Fn() -> &'a str + 'static,
R: Fn() -> &'a str + 'static,
{
self.executes.push(Box::new(execute));
self.rollbacks.push(Box::new(rollback));
}
fn execute(&self) -> Vec<&str> {
self.executes.iter().map(|cmd| cmd()).collect()
}
fn rollback(&self) -> Vec<&str> {
self.rollbacks.iter().rev().map(|cmd| cmd()).collect()
}
}
fn add_field() -> &'static str {
"add field"
}
fn remove_field() -> &'static str {
"remove field"
}
fn main() {
let mut schema = Schema::new();
schema.add_migration(|| "create table", || "drop table");
schema.add_migration(add_field, remove_field);
assert_eq!(vec!["create table", "add field"], schema.execute());
assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}
議論
コマンドが小さく、関数で定義されていたり、クロージャとして渡されていたりする場合には、動的ディスパッチを利用しない関数ポインタを使用することが望ましいでしょう。しかしコマンドが構造体で、関数や変数が別のモジュールとして定義されている場合には、トレイトオブジェクトを使用する方が適しています。応用例は actix で、routes に handler 関数を登録する際にトレイトオブジェクトを使用しています。Fn
トレイトオブジェクトを使う場合は、関数ポインタのときと同じようにコマンドを作成して使用することができます。
パフォーマンスに関しては、パフォーマンスとコードの簡潔さ・まとまりには常にトレードオフの関係があります。静的ディスパッチはよりよいパフォーマンスを提供し、動的ディスパッチはアプリケーションを構築する際の柔軟性を提供します。
参考
Interpreter
解説
問題が頻繁に発生し、それを解決するのに長いステップを繰り返す必要がある場合、その問題をシンプルな言語で表現し、インタプリタオブジェクトにこのシンプルな言語で書かれた文を解釈させることで問題を解決できるかもしれません。
基本的に、どんな種類の問題に対しても以下を定義します。
- ドメイン固有言語
- この言語のための文法
- 問題を解くインタプリタ
動機
ここでは、簡単な数式を後置記法(逆ポーランド記法)に変換することを目的としています。簡単のために、10 個の数字 0
, ..., 9
と 2 つの演算子 +
, -
で構成される式を考えます。例えば、2 + 4
という式は 2 4 +
に変換されます。
この問題に対する文脈自由文法
ここでのタスクは、中置記法を後置記法に変換することです。0
, ..., 9
, +
, -
を使った中置記法の集合を表す、以下のような文脈自由文法を定義してみましょう。
- 終端記号:
0
, ...,9
,+
,-
- 非終端記号:
exp
,term
- 開始記号:
exp
- 生成規則は以下
exp -> exp + term
exp -> exp - term
exp -> term
term -> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
NOTE: この文法は何をするかによってさらに作り替える必要があります。例えば、左再帰を取り除く必要があるかもしれません。詳細は Compilers: Principles,Techniques, and Tools (通称ドラゴン本)を参照してください。
解決法
ここでは単純に再帰下降パーサを実装します。簡単にするため、式に構文上の誤りがある場合このコードは panic します(例えば 2-34
や 2+5-
は文法定義によると誤りです)。
pub struct Interpreter<'a> {
it: std::str::Chars<'a>,
}
impl<'a> Interpreter<'a> {
pub fn new(infix: &'a str) -> Self {
Self { it: infix.chars() }
}
fn next_char(&mut self) -> Option<char> {
self.it.next()
}
pub fn interpret(&mut self, out: &mut String) {
self.term(out);
while let Some(op) = self.next_char() {
if op == '+' || op == '-' {
self.term(out);
out.push(op);
} else {
panic!("Unexpected symbol '{}'", op);
}
}
}
fn term(&mut self, out: &mut String) {
match self.next_char() {
Some(ch) if ch.is_digit(10) => out.push(ch),
Some(ch) => panic!("Unexpected symbol '{}'", ch),
None => panic!("Unexpected end of string"),
}
}
}
pub fn main() {
let mut intr = Interpreter::new("2+3");
let mut postfix = String::new();
intr.interpret(&mut postfix);
assert_eq!(postfix, "23+");
intr = Interpreter::new("1-2+3-4");
postfix.clear();
intr.interpret(&mut postfix);
assert_eq!(postfix, "12-3+4-");
}
議論
Interpreter デザインパターンは形式言語のための文法を設計し、その文法のためのパーサを実装するものだという誤った認識があるかもしれません。実は、このパターンは問題のインスタンスをより具体的に表現し、それを解決するための関数 / クラス / 構造体を定義するものです。Rust 言語には、特殊な構文とその構文をソースコードに展開するルールを定義できる macro_rules!
があります。
以下の例では、n 次元のユークリッド距離を計算する簡単な macro_rules!
を作成しました。norm!(x,1,2)
と簡単に書くことができ、 x,1,2
を Vec
に詰め込んで距離を計算する関数を呼び出すよりも効率的でしょう。
macro_rules! norm {
($($element:expr),*) => {
{
let mut n = 0.0;
$(
n += ($element as f64)*($element as f64);
)*
n.sqrt()
}
};
}
fn main() {
let x = -3f64;
let y = 4f64;
assert_eq!(3f64, norm!(x));
assert_eq!(5f64, norm!(x, y));
assert_eq!(0f64, norm!(0, 0, 0));
assert_eq!(1f64, norm!(0.5, -0.5, 0.5, -0.5));
}
参考
Newtype
ある型を別の型に似せて動作させたり、コンパイル時に何らかの動作をさせたりしたいが、型エイリアスを使うだけでは不十分な場合にはどうすればよいでしょうか?
例えば、セキュリティ上の問題(パスワードなど)から、String
に対するカスタムした Display
の実装を作成したい場合です。
そのような場合に Newtype パターンを使用して型安全とカプセル化を実現することができます。
解説
1 つのフィールドを持つタプル構造体を使って型の不透明なラッパーを作成します。これにより、型のエイリアスではなく新しい型が作成されます。
例
// いくつかの型は、必ずしも同じモジュールやクレート内にあるとは限らない
struct Foo {
//..
}
impl Foo {
// これらの関数は Bar には存在しない
//..
}
// newtype
pub struct Bar(Foo);
impl Bar {
// コンストラクタ
pub fn new(
//..
) -> Bar {
//..
}
//..
}
fn main() {
let b = Bar::new(...);
// Foo と Bar は型の互換性がないため、以下は型のチェックに通らない
// let f: Foo = b;
// let b: Bar = Foo { ... };
}
動機
newtype を使う一番の動機は抽象化です。インターフェースを正確にコントロールしながらも型同士で実装の詳細を共有することができます。実装した型を API の一部として公開するのではなく newtype を使用すれば、後方互換性を保ったまま実装を変更することができます。
newtype は単位を区別することにも使えます。例えば f64
をラップして Miles
と Kms
を区別できます。
利点
ラップされた型とラップした型では(type
を使った時とは反対に)型の互換性がありません。そのため、newtype を使うユーザはラップされた型とラップした型を「混同」してしまうことがありません。
newtype はゼロコスト抽象化で、実行時のオーバーヘッドはありません。
privacy システムによってユーザがラップされた型にアクセスできないようになっています(フィールドがプライベートの場合、デフォルトでそうなります)。
欠点
newtype の欠点(特に型エイリアスと比較した場合)は、特別な言語サポートがないことです。つまり、たくさんの定型文が必要になります。ラップされた型で公開したいメソッドには「経由させる」メソッドが必要であり、ラップされた型で公開したいトレイトはラップした型にも実装されている必要があります。
議論
Rust のコードでは newtype は非常によく使われます。抽象化や単位の表現がもっとも一般的な使われ方ですが、別の理由で使われることもあります。
- 機能の制限(公開されている関数や実装されているトレイトを減らす)
- copy セマンティクスを持つ型に move セマンティクスを持たせること
- より具体的な型を提供することで内部の型を隠した抽象化、たとえば、
pub struct Foo(Bar<T1, T2>);
この例では、Bar
は public で generic な型であるが T1
と T2
は内部の型です。このモジュールのユーザは Foo
が Bar
を使って実装されていることを知らないはずですが、ここで本当に隠しているのは、T1
と T2
という型と、それらが Bar
でどのように使われるかということです。
参考
- Advanced Types
- Haskell における newtype
- 型エイリアス
- derive_more(newtype に多くの組み込みトレイトを派生させるクレート)
- Rust における newtype パターン
RAII とガード
RAII とは「リソースの確保は初期化時に(Resource Acquisition is Initialisation)」の略です(すごい名前ですね)。このパターンの本質は、リソースの初期化はオブジェクトのコンストラクタの中でおこなわれ、終了処理はデストラクタでおこなわれるということです。このパターンが Rust では拡張され、RAII オブジェクトをリソースのガードとして使用し、アクセスが常にガードオブジェクトによって仲介されるように型システムに依存しています。
例
mutex ガードはこのパターンの std ライブラリの典型的な例です(これは実際の実装を簡略化したものです)。
use std::ops::Deref;
struct Foo {}
struct Mutex<T> {
// ここでデータ T への参照を保持する
//..
}
struct MutexGuard<'a, T: 'a> {
data: &'a T,
//..
}
// mutex を明示的にロックする
impl<T> Mutex<T> {
fn lock(&self) -> MutexGuard<T> {
// 基盤となる OS の mutex をロックする
//..
// MutexGuard は自身への参照を保持する
MutexGuard {
data: self,
//..
}
}
}
// mutex をアンロックするためのデストラクタ
impl<'a, T> Drop for MutexGuard<'a, T> {
fn drop(&mut self) {
// 基盤となる OS の mutex を アンロックする
//..
}
}
// Deref を実装することで MutexGuard を T へのポインタのように扱うことができる
impl<'a, T> Deref for MutexGuard<'a, T> {
type Target = T;
fn deref(&self) -> &T {
self.data
}
}
fn baz(x: Mutex<Foo>) {
let xx = x.lock();
xx.foo(); // foo は Foo のメソッド.
// 借用チェッカーにより、ガード xx よりも生存期間の長い Foo への参照は保持できない
// x はこの関数が終了して xx のデストラクタが実行されたときにアンロックされる
}
動機
リソースを使った後に終了処理をおこなう必要がある場合、RAII を使用してこの終了処理をおこなうことができます。終了処理をおこなった後リソースにアクセスするとエラーになる場合、このパターンを使ってそのようなエラーを防ぐことができます。
利点
リソースの終了処理が行われていない場合と、リソースが終了処理をおこなった後に使われてしまう場合のエラーを防ぐことができます。
議論
RAII はリソースのデアロケートや終了処理を適切におこなうために有効なパターンです。Rust の借用チェッカーを利用することで、終了処理がおこなわれた後にリソースが使用されることによるエラーを静的に防ぐことができます。
借用チェッカーの主な目的は、データの参照がそのデータよりも長く生存しないようにすることです。RAII ガードパターンが機能するのは、ガードオブジェクトがリソースへの参照を含んでおり、そのような参照を公開しているからです。Rust はガードがリソースより長く生存しないようにし、ガードが仲介するリソースへの参照がガードより長く生存できないことを保証します。この仕組みを理解するには、ライフタイムを省略せず deref
のシグニチャを調べてみるとよいでしょう。
fn deref<'a>(&'a self) -> &'a T {
//..
}
返されるリソースへの参照は self
と同じライフタイム('a
)を持ちます。借用チェッカーはそのため T
への参照のライフタイムが self
のライフタイムよりも短いことを保証します。
ただし、Deref
の実装はこのパターンの核心的な部分ではなく、ガードオブジェクトをより使いやすくしているだけです。ガードに get
メソッドを実装することでも同様に動作します。
参考
- デストラクタで終了処理をおこなうイディオム
- RAII は C++ においてよく使われるパターンです: cppreference.com、wikipedia
- スタイルガイド(今は単なるプレースホルダになっています)
Strategy(別名 Policy)
解説
Strategy デザインパターンは関心の分離を可能にするテクニックです。また、依存性の逆転によってソフトウェアモジュールを切り離すこともできます。
Strategy パターンの基本的な考え方は、例えば特定の問題を解くアルゴリズムが与えられたとき、抽象的なレベルでアルゴリズムの骨組みのみを定義し、特定のアルゴリズムの実装は他の部分に分離するというものです。
このようにすれば、アルゴリズムを使用するクライアントは、一般的なアルゴリズムのワークフローはそのままに、特定の実装を選ぶことができます。つまり、クラスの抽象的な仕様は派生クラスの具体的な実装に依存しませんが、具体的な実装は抽象的な仕様を守らなければなりません。これが「依存性の逆転」と呼ばれる理由です。
動機
毎月の帳票を生成するプロジェクトに取り組んでいるとします。レポートは、JSON
、Plain Text
など、さまざまなフォーマット(ストラテジー)で生成する必要があります。しかし物事は時間とともに変化するものであり、将来どのような要求が出てくるかわかりません。例えば、帳票をまったく新しいフォーマットで生成する必要がある場合や、既存のフォーマットを 1 つだけ変更する必要がある場合もあるかもしれません。
例
この例では、Context
、Formatter
、Report
は不変のもの(もしくは抽象化したもの)で、Text
、Json
はストラテジー構造体です。これらのストラテジーは Formatter
トレイトを実装しなければなりません。
use std::collections::HashMap;
type Data = HashMap<String, u32>;
trait Formatter {
fn format(&self, data: &Data, buf: &mut String);
}
struct Report;
impl Report {
// 本来なら write を使うべきですが、エラー処理を無視するため String のままにしている
fn generate<T: Formatter>(g: T, s: &mut String) {
// バックエンド処理...
let mut data = HashMap::new();
data.insert("one".to_string(), 1);
data.insert("two".to_string(), 2);
// 帳票生成
g.format(&data, s);
}
}
struct Text;
impl Formatter for Text {
fn format(&self, data: &Data, buf: &mut String) {
for (k, v) in data {
let entry = format!("{} {}\n", k, v);
buf.push_str(&entry);
}
}
}
struct Json;
impl Formatter for Json {
fn format(&self, data: &Data, buf: &mut String) {
buf.push('[');
for (k, v) in data.into_iter() {
let entry = format!(r#"{{"{}":"{}"}}"#, k, v);
buf.push_str(&entry);
buf.push(',');
}
buf.pop(); // 最後の余分な , を取り除く
buf.push(']');
}
}
fn main() {
let mut s = String::from("");
Report::generate(Text, &mut s);
assert!(s.contains("one 1"));
assert!(s.contains("two 2"));
s.clear(); // 同じバッファを再利用する
Report::generate(Json, &mut s);
assert!(s.contains(r#"{"one":"1"}"#));
assert!(s.contains(r#"{"two":"2"}"#));
}
利点
主な利点は関心の分離です。この例では Report
は Json
や Text
の特定の実装について何も知らないのに対し、出力の実装ではデータの前処理、保存、取得の方法について何も知りません。コンテキストと、Formatter
や format
のような特定のトレイトやメソッドの実装のみを知っていればよいのです。
議論
前述の例では、すべてのストラテジーが 1 つのファイルに実装されています。異なるストラテジーを提供する方法には以下のものがあります。
- すべてを 1 つのファイルにまとめる(この例のようにする。モジュールとして分割されているのと似ている。)
- モジュールとして分割する(
formatter::json
モジュール、formatter:text
モジュールなど) - コンパイラの feature フラグを使用する(
json
feature、text
feature) - クレートとして分割する(
json
クレート、text
クレートなど)
Serde クレートは Strategy パターンのよい例です。Serde では自分の型に対して Serialize
と Deserialize
トレイトを実装することでシリアル化の動作をカスタマイズすることができます。例えば、serde_json
と serde_cbor
は似たようなメソッドを公開しているため、簡単に入れ替えることができます。これをおこなうと、serde_transcode
というヘルパークレートが便利で使いやすくなります。
しかし、Rust でこのパターンを設計するのにトレイトを使う必要はありません。
以下の例は Rust のクロージャを使って Strategy パターンの考え方を示しています。
struct Adder;
impl Adder {
pub fn add<F>(x: u8, y: u8, f: F) -> u8
where
F: Fn(u8, u8) -> u8,
{
f(x, y)
}
}
fn main() {
let arith_adder = |x, y| x + y;
let bool_adder = |x, y| {
if x == 1 || y == 1 {
1
} else {
0
}
};
let custom_adder = |x, y| 2 * x + y;
assert_eq!(9, Adder::add(4, 5, arith_adder));
assert_eq!(0, Adder::add(0, 0, bool_adder));
assert_eq!(5, Adder::add(1, 3, custom_adder));
}
実は、Rust ではOption
の map
メソッドですでにこのアイデアを採用しています。
fn main() {
let val = Some("Rust");
let len_strategy = |s: &str| s.len();
assert_eq!(4, val.map(len_strategy).unwrap());
let first_byte_strategy = |s: &str| s.bytes().next().unwrap();
assert_eq!(82, val.map(first_byte_strategy).unwrap());
}
参考
Visitor
解説
ビジターとは、異種のオブジェクトのコレクションを操作するアルゴリズムをカプセル化したものです。これにより、データ(またはその主要な動作)を変更することなく、複数の異なるアルゴリズムを同じデータに対して記述することができます。
さらに、visitor パターンでは、オブジェクトのコレクションの走査を、各オブジェクトに対して実行される操作から分離することができます。
例
// 訪問先のデータ
mod ast {
pub enum Stmt {
Expr(Expr),
Let(Name, Expr),
}
pub struct Name {
value: String,
}
pub enum Expr {
IntLit(i64),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
}
}
// 抽象化されたビジター
mod visit {
use ast::*;
pub trait Visitor<T> {
fn visit_name(&mut self, n: &Name) -> T;
fn visit_stmt(&mut self, s: &Stmt) -> T;
fn visit_expr(&mut self, e: &Expr) -> T;
}
}
use visit::*;
use ast::*;
// 具体的な実装例 - AST をコードとして解釈しながら走査する
struct Interpreter;
impl Visitor<i64> for Interpreter {
fn visit_name(&mut self, n: &Name) -> i64 { panic!() }
fn visit_stmt(&mut self, s: &Stmt) -> i64 {
match *s {
Stmt::Expr(ref e) => self.visit_expr(e),
Stmt::Let(..) => unimplemented!(),
}
}
fn visit_expr(&mut self, e: &Expr) -> i64 {
match *e {
Expr::IntLit(n) => n,
Expr::Add(ref lhs, ref rhs) => self.visit_expr(lhs) + self.visit_expr(rhs),
Expr::Sub(ref lhs, ref rhs) => self.visit_expr(lhs) - self.visit_expr(rhs),
}
}
}
AST のデータに手を加えることなく、型チェッカーなどのビジターを追加実装することができます。
動機
visitor パターンは異種混合のデータに対してアルゴリズムを適用したい場合に有効です。同種のデータであればイテレータのようなパターンを使うことができます。関数的なアプローチではなくビジターオブジェクトを使うことで、ビジターはステートフルになり、ノード間で情報をやりとりできるようになります。
議論
visit_*
メソッドは、例とは異なり void を返すのが一般的です。その場合、走査コードを抽出してアルゴリズム間で共有することが可能です(また、noop なデフォルトメソッドを提供することもできます)。Rust では、各データに対して walk_*
関数を用意するのが一般的な方法です。
pub fn walk_expr(visitor: &mut Visitor, e: &Expr) {
match *e {
Expr::IntLit(_) => {},
Expr::Add(ref lhs, ref rhs) => {
visitor.visit_expr(lhs);
visitor.visit_expr(rhs);
}
Expr::Sub(ref lhs, ref rhs) => {
visitor.visit_expr(lhs);
visitor.visit_expr(rhs);
}
}
}
他の言語(Java など)では、データに対し同じ役割を果たす accept
メソッドが用意されているのが一般的です。
参考
visitor パターンはほとんどのオブジェクト指向言語に共通するパターンです。
fold パターンは visitor と似ていますが、訪問したデータ構造の新しいバージョンを生成するものです。
生成に関するパターン
Wikipedia より:
オブジェクトの生成の仕組みを扱うデザインパターンであり、状況に適した方法でオブジェクトを生成しようとするパターンです。オブジェクト生成の基本的な形では、設計上の問題が発生したり、設計の複雑さが増したりする可能性があります。生成に関するパターンではオブジェクト生成を何らかの方法で制御することで、この問題を解決します。
Builder
解説
ビルダーヘルパーを呼び出してオブジェクトを構築します。
例
#[derive(Debug, PartialEq)]
pub struct Foo {
// 複雑なフィールドがたくさんある
bar: String,
}
impl Foo {
// このメソッドにより、ユーザはビルダーを見つけやすくなる
pub fn builder() -> FooBuilder {
FooBuilder::default()
}
}
#[derive(Default)]
pub struct FooBuilder {
// optional なフィールドがたくさんあるかもしれない
bar: String,
}
impl FooBuilder {
pub fn new(/* ... */) -> FooBuilder {
// Foo に必要な最小限のフィールドを設定する
FooBuilder {
bar: String::from("X"),
}
}
pub fn name(mut self, bar: String) -> FooBuilder {
// ビルダー自身に名前を設定し、ビルダーを値として返す
self.bar = bar;
self
}
// ここでビルダーを消費せずに済む場合には利点がある。
// FooBuilder をテンプレートとして、たくさんの Foo を構築することができる。
pub fn build(self) -> Foo {
// FooBuilder から Foo を作成し、FooBuilder のすべての設定を Foo に適用する
Foo { bar: self.bar }
}
}
#[test]
fn builder_test() {
let foo = Foo {
bar: String::from("Y"),
};
let foo_from_builder: Foo = FooBuilder::new().name(String::from("Y")).build();
assert_eq!(foo, foo_from_builder);
}
動機
多くの異なるコンストラクタを必要とする場合や、コンストラクタに副作用がある場合に有効です。
利点
構築するためのメソッドを他のメソッドから分離することができます。
コンストラクタの増加を防ぐことができます。
ワンライナーの初期化にも、より複雑な構築にも使用することができます。
欠点
構造体オブジェクトを直接生成したり、単純なコンストラクタ関数を作成するよりも複雑です。
議論
Rust にはオーバーロードがないため、このパターンは他の言語よりも(単純なオブジェクトに対して)頻繁に見られます。与えられた名前を持つメソッドは 1 つしか持てないため、Rust で複数のコンストラクタを持つことは C++ や Java などに比べて好ましくありません。
このパターンは、ビルダーオブジェクトが単なるビルダーではなく、それ自身が有用である場合によく使われます。例えば、std::process::Command は Child(プロセス)に対するビルダーになっています。このような場合には T
と TBuilder
のパターンでの命名は使われません。
今回の例では、ビルダーを値として受け取り、返すようにしています。しかし、ビルダーを可変の参照として受け取り、返す方が便利で効率的であることが多いです。借用チェッカーによってこれを自然におこなうことができます。この方法では、次のようなコードを書くができるという利点があります。
let mut fb = FooBuilder::new();
fb.a();
fb.b();
let f = fb.build();
FooBuilder::new().a().b().build()
のようなスタイルで書くこともできます。
参考
- スタイルガイドにおける記述
- derive_builder、このパターンを自動的に実装するためのクレートで、定型文を避けることができます。
- Constructor パターンはコンストラクションをシンプルにすることができる。
- Builder パターン(wikipedia)
- 複雑な値の構築
Fold
解説
データのコレクションの各アイテムに対してアルゴリズムを実行して新しいアイテムを作成し、まったく新しいコレクションを作成します。
この語源はよくわかりません。Rust のコンパイラでは「fold」や「folder」という言葉が使われていますが、通常の意味では fold よりも map の方が近いと思われます。詳細は以下の議論を参照してください。
例
// fold するデータ、簡単な AST
mod ast {
pub enum Stmt {
Expr(Box<Expr>),
Let(Box<Name>, Box<Expr>),
}
pub struct Name {
value: String,
}
pub enum Expr {
IntLit(i64),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
}
}
// 抽象化された folder
mod fold {
use ast::*;
pub trait Folder {
// 葉ノードはノードそのものを返すだけ
// 場合によっては、中間ノードもこのようにできる
fn fold_name(&mut self, n: Box<Name>) -> Box<Name> { n }
// 子供を fold して新しく中間ノードを作成する
fn fold_stmt(&mut self, s: Box<Stmt>) -> Box<Stmt> {
match *s {
Stmt::Expr(e) => Box::new(Stmt::Expr(self.fold_expr(e))),
Stmt::Let(n, e) => Box::new(Stmt::Let(self.fold_name(n), self.fold_expr(e))),
}
}
fn fold_expr(&mut self, e: Box<Expr>) -> Box<Expr> { ... }
}
}
use fold::*;
use ast::*;
// 具体的な実装例 - すべての名前を 'foo' にリネームする
struct Renamer;
impl Folder for Renamer {
fn fold_name(&mut self, n: Box<Name>) -> Box<Name> {
Box::new(Name { value: "foo".to_owned() })
}
// 他のノードには、デフォルトメソッドを使用する
}
AST に対して Renamer
を実行した結果、古い AST と同じですが、すべての名前が foo
に変更された新しい AST ができあがります。実際の folder には、構造体自身にノード間で保存された状態があるかもしれません。
folder はデータ構造を別の(通常は似たような)データ構造にマッピングするために定義することもできます。例えば、AST を HIR 木に fold することができます(HIR は high-level intermediate representation の略です)。
動機
データ構造の各ノードに対して何らかの操作をおこない、データ構造を変換したいということがよくあります。単純なデータ構造に対する単純な操作であれば、Iterator::map
を使って行うことができます。前のノードが後のノードの操作に影響を与える場合や、データ構造のイテレーションが自明でない場合など、より複雑な操作をおこなう場合には、fold パターンの使用が適しています。
visitor パターンと同様に、fold パターンでもデータ構造の走査と、各ノードに対して実行される操作を分離することができます。
議論
このようにデータ構造をマッピングすることは、関数型言語ではよくあることです。オブジェクト指向言語では、データ構造をその場で書き換えてしまうのが一般的でしょう。「関数型」のアプローチは Rust でよく使われていますが、これは主に immutability が好まれるためです。古いデータ構造を変更するのではなく、新たに作成したデータ構造を使用することで、ほとんどの状況でコードが理解しやすくなります。
効率性と再利用性のトレードオフは、fold_*
メソッドがノードを受け付ける方法を変更することで調整できます。
上の例では、Box
ポインタを操作しています。これらはデータを排他的に所有しているため、データ構造の元のコピーを再利用することができません。一方、ノードが変更されない場合には、再利用すると非常に効率的です。
借用された参照に対して操作をするのであれば、元のデータ構造を再利用することができますが、ノードが変更されていなくてもクローンしなければならず、コストがかかります。
参照カウントポインタを使用すると、元のデータ構造を再利用でき、変更されていないノードを複製する必要がないという、両方の利点があります。しかし、データ構造を mutable にできず、使いにくいです。
参考
イテレータには fold
メソッドがありますが、これはデータ構造を新たなデータ構造にするのではなく、データ構造を値に畳み込むものです。イテレータの map
がこの fold パターンに近いです。
他の言語では、fold
はこのパターンではなく、Rust のイテレータと同じ意味で使われることが普通です。関数型言語の中には、データ構造に対して柔軟なマッピングを行うための強力な構文を持っているものがあります。
visitor パターンは fold と密接な関係があります。データ構造を走査し、各ノードに対して操作を行うというコンセプトを共有しています。しかし、visitor は新たなデータ構造を作成することも、古いデータ構造を消費してしまうこともありません。
構造に関するパターン
Wikipedia より:
エンティティ間の関係を実現する簡単な方法を定めることで、設計を容易にするデザインパターンです。
構造体をまとめる
解説
構造体が大きいと借用チェッカーで問題が発生することがあります。フィールドは独立して借用できますが、構造体全体が一度に使われ、他の使用が妨げられることがあります。解決策として、構造体をいくつかの小さな構造体に分解して、それらを元の構造体に合成することが考えられます。そうすれば、各構造体を個別に借用でき、より柔軟な動作が可能になります。
これにより別の意味でもよい設計になることが多いです。このデザインパターンを適用することで、より小さな単位の機能が見えてくることが多いのです。
例
ここでは、構造体を使用しようとしたときに、借用チェッカーによって失敗した例を紹介します。
struct A {
f1: u32,
f2: u32,
f3: u32,
}
fn foo(a: &mut A) -> &u32 { &a.f2 }
fn bar(a: &mut A) -> u32 { a.f1 + a.f3 }
fn baz(a: &mut A) {
// 後で x を使うので、関数の残りの部分で a が借用されることになる
let x = foo(a);
// 借用チェッカーエラー:
// let y = bar(a); // ~ ERROR: cannot borrow `*a` as mutable more than once
println!("{}", x);
}
このデザインパターンを適用し、A をリファクタリングして 2 つの小さな構造体にすることで、借用チェックの問題を解決することができます。
// A は、B と C の 2 つの構造体で構成されるようになった
struct A {
b: B,
c: C,
}
struct B {
f2: u32,
}
struct C {
f1: u32,
f3: u32,
}
// これらの関数は A ではなく、B か C を受け取る
fn foo(b: &mut B) -> &u32 { &b.f2 }
fn bar(c: &mut C) -> u32 { c.f1 + c.f3 }
fn baz(a: &mut A) {
let x = foo(&mut a.b);
// 今度は大丈夫!
let y = bar(&mut a.c);
println!("{}", x);
}
利点
借用チェッカーの制限を回避することができます。
多くの場合、よりよい設計になることがあります。
欠点
より冗長なコードになります。
小さな構造体は抽象化がうまくいかず、結果的に悪い設計になってしまうことがあります。これはおそらく「コードの臭い」であり、何らかの方法でプログラムをリファクタリングする必要があることを示しています。
議論
このパターンは借用チェッカーを持たない言語では必要とされていないため、その意味では Rust に特有のものです。しかし、機能を小さな単位にするとコードがきれいになることがよくあります。これは、言語に関係なくソフトウェアエンジニアリングの原則として広く認識されています。
このパターンは、Rust の借用チェッカーがフィールドを独立して借用できるかに依存しています。この例では、借用チェッカーが a.b
と a.c
は別個で独立して借用できることを知っているため、a
のすべてを借用しようとしませんが、そうするとこのパターンは役に立たなくなります。
小さなクレートがよい
解説
1 つのことに集中できる小さなクレートがよいです。
Cargo と crates.io によって、C や C++ よりも簡単にサードパーティ製のライブラリを追加できます。さらに、crates.io のパッケージは公開した後に編集や削除ができないため、現在動作しているビルドは将来的にも継続して動作します。このツールを活用し、小さく、より細かい依存関係を使用すべきです。
利点
- 小さなクレートは理解しやすく、モジュール化されたコードを促進します。
- クレートによって、プロジェクト間でコードを再利用することができます。例えば、
url
クレートは Servo ブラウザエンジンの一部として開発されましたが、プロジェクトその外でも広く使われるようになりました。 - Rust のコンパイル単位は crate なので、プロジェクトを複数のクレートに分割することで、より多くのコードを並列にビルドすることができます。
欠点
- プロジェクトが同時に複数の競合するバージョンのクレートに依存する「依存関係地獄」を引き起こす可能性があります。例えば、
url
クレートにはバージョン 1.0 と 0.5 があります。url:1.0
の Url とurl:0.5
の Url は異なる型であるため、url:0.5
を使用する HTTP クライアントはurl:1.0
を使用する Web スクレイパーからの Url の値を受け付けません。 - crates.io のパッケージはキュレーションされていません。書き方が下手であったり、参考にならないドキュメントがあったり、明らかに悪意のあるものであったりします。
- コンパイラはデフォルトでリンク時最適化(Link Time Optimization、LTO)を行わないため、小さなクレート 2 つは、大きなクレート 1 つのときよりも最適化されない場合があります。
例
ref_slice クレートは、&T
を &[T]
に変換する関数を提供します。
url クレートは、URL を扱うためのツールを提供します。
num_cpus クレートは、マシンの CPU の数を問い合わせる関数を提供します。
参考
unsafety を小さなモジュールに閉じ込める
解説
unsafe
なコードがある場合、そこに安全な最小限のインターフェースを構築するのに必要な不変性を保持する最小のモジュールを作成します。このモジュールを安全で使いやすいインターフェースを持つモジュールに組み込みます。なお、外側のモジュールには、unsafe なコードを直接呼び出す unsafe な関数やメソッドを含めることができます。ユーザはこれを利用してスピードアップを図ることができます。
利点
- 精査しなくてはならない unsafe なコードが制限されます。
- 内側のモジュールの保証に頼れる分、外側のモジュールを書くことが非常に簡単です。
欠点
- 適切なインターフェースを見つけるのが難しいことがあります。
- 抽象化により非効率性が生じることがあります。
例
- toolshed クレートはサブモジュールに unsafe な操作を保持し、ユーザに安全なインタフェースを提供しています。
-
std
のString
クラスはVec<u8>
のラッパーで、内容が有効な UTF-8 でなければならないという不変性が追加されています。String
に対する操作はこの動作を保証します。しかし、ユーザはString
を作成するためにunsafe
なメソッドを使用することができます。この場合、内容が有効であることを保証する責任はユーザにあります。
参考
アンチパターン
アンチパターンとは、「通常は無駄で非常に逆効果になる危険性のある、繰り返される問題」の解決方法のことです。問題の解決方法を知るのと同様に、問題を解決しない方法を知ることにも価値があります。アンチパターンは、デザインパターンに関連して考えるべき、よい反例を与えてくれます。アンチパターンはコードに限られた話ではありません。例えば、プロセスもアンチパターンになり得ます。
借用チェッカーを満足させるために clone する
解説
借用チェッカーは、Rust のユーザが unsafe なコードを開発しないように次のどちらかを保証します: 可変参照が 1 つだけ存在すること、もしくは、参照がたくさん存在するかもしれないがすべて不変参照になっていること。コードがこれらの条件を満たさない場合に、開発者が変数を clone してコンパイルエラーを解消する、というアンチパターンが発生します。
例
// 任意の変数を定義する
let mut x = 5;
// `x` を借用する -- まず clone しておく
let y = &mut (x.clone());
// 最適化され消えてしまわないように、借用に対して何らかの操作をおこなっておく
*y += 1;
// 2 行前の x.clone() がなければ x が借用されているため、この行はコンパイルに失敗する
// x.clone() のおかげで、x は借用されず、この行が実行される
println!("{}", x);
動機
特に初心者の方は、このパターンを使って借用チェッカーのわかりにくい問題を解決したいと思うかもしれません。しかし、深刻な結果が待っています。.clone()
を使うとデータのコピーが作成されます。2 つのデータの変更は同期されず、2 つのまったく別の変数が存在するかのようになります。
ただし、特別なケースもあります。Rc<T>
は clone を賢く扱うように設計されています。内部ではデータのコピーを 1 つだけ管理しており、それを clone すると参照のみ複製されます。
また、Arc<T>
はヒープ上に割り当てられた T
型の値の所有権を共有します。Arc
に対して .clone()
を呼び出すと新たな Arc
インスタンスが生成されます。このインスタンスは元の Arc
とヒープ上の同じ領域を指し示しますが、参照カウントが増加します。
一般的に、clone は結果について十分に理解した上で慎重におこなわれるべきです。借用チェッカーのエラーを消すために clone が使われている場合には、このアンチパターンが使われている可能性があります。
.clone
が悪いパターンを示していたとしても、以下のような場合には、非効率的なコードを書いても問題ないことがあります。
- 開発者が所有権に慣れていない場合
- ハッカソンやプロトタイプのように、コードに速度やメモリの大きな制約がない場合
- 借用チェックが非常に複雑で、パフォーマンスよりも読みやすさを優先したい場合
不必要な clone であることが疑われる場合には、Rust Book の所有権の章を十分に理解した上で clone が必要かどうかを判断する必要があります。
また、プロジェクトに cargo clippy
を実行させておくと、1、2、3、4 のような .clone()
が不要なケースを検出してくれます。
参考
- 変更された enum の所有権を保持するために mem:{take(), replace()} を使う
- .clone() を賢く扱う Rc のドキュメント
- thread-safe な参照カウントポインタである Arc のドキュメント
- Rust における所有権のトリック
#![deny(warnings)]
解説
善意を持ったクレート作者は、コードが警告なしでビルドされることを望んでいます。そこで、クレートのルートに次のような注釈をつけてしまいます。
例
#![deny(warnings)]
// すべて順調です
利点
短く書くことができ、何か問題があるときはビルドは停止します。
欠点
コンパイラがビルド時に警告を出すことを禁止すると、クレートの作者は Rust の安定版から外れてしまうことがあります。新機能や古い誤った機能のために処理方法の変更が必要になることがあります。そのため、deny
になる前に 一定の期間 warn
となるような lint が書かれます。
例えば、同じメソッドに対して 2 つの impl
を持てることが発見されました。これはよくないことだと考えられましたが、移行をスムーズにするために、overlapping-inherent-impls
という lint が導入され、将来のリリースでハードエラーになる前に、この現象につまずいた人に警告を与えました。
また、API が deprecated になることがあり、そのときには以前は何もなかったのに警告が出るようになります。
これらのことは、何かが変わるたびに、ビルドが壊れる可能性を秘めています。
さらに、追加の lint を提供するクレート(rust-clippy など)はアノテーションが削除されない限り使用できなくなります。これは --cap-lints をつけると緩和されます。コマンドライン引数 --cap-lints=warn
はすべての deny
の lint エラーを警告に変えます。しかし、forbit
lint は deny
よりも強いため、「forbit」 レベルを上書きしてエラーよりも低いレベルにすることはできないことに注意してください。その結果、forbit
lint はコンパイルを停止してしまいます。
代替案
この問題に取り組むには 2 つの方法があります。1 つ目はビルドの設定をコードから切り離すこと、2 つ目は拒否したい lint を明示的に指定することです。
以下のコマンドラインでは、すべての警告を deny
に設定してビルドします。
RUSTFLAGS="-D warnings" cargo build
これは、コードに変更を加えることなく開発者個人がおこなうことができます(Travis のような CI ツールでも設定することができますが、何かが変更された時ビルドが壊れる可能性があることに注意してください)。
代わりに、コードの中で deny
にしたい lint を指定することもできます。ここでは、(たぶん)拒否しても安全な警告 lint の一覧を紹介します(Rustc 1.48.0 時点)。
#[deny(bad-style,
const-err,
dead-code,
improper-ctypes,
non-shorthand-field-patterns,
no-mangle-generic-items,
overflowing-literals,
path-statements ,
patterns-in-fns-without-body,
private-in-public,
unconditional-recursion,
unused,
unused-allocation,
unused-comparisons,
unused-parens,
while-true)]
さらに、以下のような allow
されている lint は deny
にしてもよいと思います。
#[deny(missing-debug-implementations,
missing-docs,
trivial-casts,
trivial-numeric-casts,
unused-extern-crates,
unused-import-braces,
unused-qualifications,
unused-results)]
missing-copy-implementations
をリストに追加したい人もいるでしょう。
なお、将来的に deprecated となる API が増えることはかなり確実なので、明示的に deprecated
の lint は追加していません。
参考
- clippy の lint
- deprecate attribute のドキュメント
-
rustc -W help
と入力すると、システム上に lint のリストが表示されます。また、オプションの一覧を知るにはrustc --help
と入力してください。 - rust-clippy は Rust のコードをよりよくするための lint のコレクションです。
Deref
ポリモーフィズム
解説
Deref
トレイトを乱用して構造体の継承をエミュレートし、メソッドを再利用します。
例
Java などオブジェクト指向言語でよく見られる次のようなパターンを模倣したいことがあります。
class Foo {
void m() { ... }
}
class Bar extends Foo {}
public static void main(String[] args) {
Bar b = new Bar();
b.m();
}
deref ポリモーフィズム・アンチパターンを使用して、そのようにすることができます。
use std::ops::Deref;
struct Foo {}
impl Foo {
fn m(&self) {
//..
}
}
struct Bar {
f: Foo,
}
impl Deref for Bar {
type Target = Foo;
fn deref(&self) -> &Foo {
&self.f
}
}
fn main() {
let b = Bar { f: Foo {} };
b.m();
}
Rust には構造体の継承がありません。代わりに合成を使って Bar
の中に Foo
のインスタンスを含めています(フィールドは値であるため、インラインで保存されており、フィールドがあったとしたら Java 版と同じメモリ配置になるはずです(おそらく、念のため #[repr(C)]
を使った方がよいです))。
メソッドを呼び出すために Foo
をターゲットにして Bar
に Deref
を実装しています(埋め込まれた Foo
のフィールドを返してくれます)。つまり、Bar
を(例えば *
を使って)参照外しすると Foo
が返ってくるのです。これはかなり奇妙なことです。通常は T
の参照から T
が得られますが、ここでは 2 つの無関係な型があります。しかし、ドット演算子は暗黙のうちに参照外しをおこなうため、メソッド呼び出しで Bar
だけでなく Foo
のメソッドも検索されることになるのです。
利点
定型分が少なくなります。
impl Bar {
fn m(&self) {
self.f.m()
}
}
欠点
もっとも重要なことは、これは驚くべきイディオムであるということです。このコードを読む未来のプログラマは、このようなことが起こるとは思わないでしょう。それは、Deref
トレイトを意図された(そしてドキュメント化された)通りに使うのではなく乱用してしまっているからです。また、このメカニズムは完全に暗黙的であるからです。
このパターンでは、Java や C++ のように Foo
と Bar
のサブタイプを導入することはできません。さらに、Foo
によって実装されたトレイトは自動的に Bar
にも実装されます。そのためこのパターンは境界チェックや generic プログラミングと相性が悪いです。
このパターンを使うと、self
に関して多くのオブジェクト指向言語とは微妙に異なったセマンティクスを得られます。通常は self
はサブクラスへの参照になりますが、このパターンではメソッドが定義されている「クラス」になります。
また、このパターンは単一継承しかサポートしておらず、インターフェースやクラスベースの privacy、継承に関連した機能が一切ありません。そのため、Java の継承に慣れているプログラマは微妙に驚きを得る体験になるでしょう。
議論
よい代替案は 1 つもありません。状況に応じてトレイトを再実装したり、Foo
に dispatch するようなファサードメソッドを手動で書くのがよいかもしれません。Rust に継承のようなメカニズムを導入するつもりですが、安定版の Rust になるまでは時間がかかりそうです。詳細はブログ記事と RFC の issue を参照してください。
Deref
トレイトはカスタムポインタ型を実装するためのものです。その意図は T
へのポインタを T
にするものであり、異なる型に変換するものではありません。これがトレイトの定義で強制されていない(おそらくできない)のは残念です。
Rust は、明示的なメカニズムと暗黙的なメカニズムのバランスを慎重に取り、型の明示的な変換を優先しています。ドット演算子の自動参照外しは暗黙的なメカニズムを強く支持しているケースですが、任意の型への変換ではなく、間接的な度合いに限定されることを意図しています。
参考
- コレクションはスマートポインタの idiom
- delegate や ambassador のような定型分の少ない委譲のクレート
Deref
トレイトのドキュメント
関数型プログラミング
Rust は命令型言語ですが、多くの関数型言語のパラダイムにしたがっています。
コンピュータサイエンスにおいて、関数型プログラミングとは、関数を適用したり合成したりすることでプログラムを構築するプログラミングパラダイムである。関数の定義は、プログラムの状態を変化させる一連の命令文ではなく、それぞれが値を返すツリーであるという宣言型プログラミングのパラダイムである。
プログラミングのパラダイム
命令型を学んできた人が関数型のプログラムを理解する上での一番大きな障害となるのが、発想の転換です。命令型のプログラムではどのようにするかを記述しますが、宣言型のプログラムは何をするかを記述します。このことを示すために、1 から 10 までの数字の合計を計算してみましょう。
命令型
let mut sum = 0;
for i in 1..11 {
sum += i;
}
println!("{}", sum);
命令型のプログラムでは、何が起こっているのかを確認するためにコンパイラを実行しなければなりません。ここでは、sum
を 0
とします。次に、1 から 10 までの範囲で反復します。ループを繰り返すたびに、範囲内に対応する値を加算します。そして、それをプリントアウトします。
i |
sum |
---|---|
1 | 1 |
2 | 3 |
3 | 6 |
4 | 10 |
5 | 15 |
6 | 21 |
7 | 28 |
8 | 36 |
9 | 45 |
10 | 55 |
多くの人は、このようにしてプログラミングをはじめます。プログラムは一連の手順であることを学びます。
宣言型
println!("{}", (1..11).fold(0, |a, b| a + b));
わぉ!これは本当に違う!どうなってるんでしょう?宣言型のプログラムではどのようにするかではなく何をするかを記述していることを覚えておいてください。fold
は関数を合成する関数です。この名前は Haskell での慣習です。
ここでは、1 から 10 までの範囲で、足し算をする関数(クロージャ: |a, b| a + b
)を合成しています。0
は出発地点で、a
は最初は 0
です。b
は範囲の最初の要素なので 1
です。 0 + 1 = 1
が結果となります。ここでもう一度 fold
して、a = 1
、b = 2
となり、1 + 2 = 3
が次の結果となります。このプロセスは、範囲内の最後の要素の 10
に辿り着くまで続きます。
a |
b |
result |
---|---|---|
0 | 1 | 1 |
1 | 2 | 3 |
3 | 3 | 6 |
6 | 4 | 10 |
10 | 5 | 15 |
15 | 6 | 21 |
21 | 7 | 28 |
28 | 8 | 36 |
36 | 9 | 45 |
45 | 10 | 55 |