ジェネリクスとトレイト――Rustでジェネリクス型を実装する:基本からしっかり学ぶRust入門(11)
Rustについて基本からしっかり学んでいく本連載。第11回は、Rustのジェネリクスとトレイトについて。
第11回は、独自のジェネリクス型を定義し、メソッドを実装してみるという過程を通じて、メソッドと切り離せない重要な概念であるトレイト(trait)を紹介します。
ジェネリクス型を定義する
第9回と第10回で、ジェネリクスとコレクションを紹介しました。ジェネリクスとは、データ型を抽象化することでコードの再利用を容易にする仕組みです。ジェネリクスを使用することで、抽象化されたデータ型に対してのみ定義や処理内容を記述すればよくなり、コードの重複を防いでメンテナンス性も向上させることができます。第9回と第10回では、標準ライブラリで定義済みのコレクション(ベクター、ハッシュマップなど)を扱うことでジェネリクスについて触れてきましたが、今回は独自のジェネリクス型を定義してメソッドを実装しながら、理解を深めていきます。
構造体をジェネリクス型として定義する
標準ライブラリが備えるコレクションであるVec<T>型やHashMap<K, V>型と同様に、独自の構造体もジェネリクス型として定義できます。struct文において、構造体の名前に型パラメーターを付記し、その型パラメーターを用いてジェネリクス型に依存するフィールドのデータ型を指定します。以下は、範囲を保持する構造体Range型をジェネリクス型として定義する例です。
struct Range<T> { (1) min: T, max: T, step: T, current: T, } fn main() { let int_range = Range {min: 1, max: 10, step:1, current: -1}; (2) let float_range = Range {min: 1.0, max: 100.0, step: 0.1, current: -1.0}; //let mixed_range = Range {min: 1.0, max: 10, step: 1, current: -1.0}; (3) println!("min: {}, max: {}, step: {}, current:{}", int_range.min, int_range.max, int_range.step, int_range.current); // min: 1, max: 10, step: 1, current:-1 println!("min: {}, max: {}, step: {}, current:{}", float_range.min, float_range.max, float_range.step, float_range.current); // min: 1, max: 100, step: 0.1, current:-1 }
(1)は、構造体Rangeをジェネリクス型として定義しています。型パラメーターにはTが渡されているので、それを用いてmin、max、step、currentの4つのフィールドを宣言しています。このように、構造体をジェネリクス型として定義するのは簡単です。HashMap<K, V>型のように、型パラメーターを複数にする場合も同様に定義できます。
(2)では、Range型を使って実際にインスタンス変数を宣言しています。構造体のインスタンスの初期化については第7回で紹介した通りです。Rustでは、初期化に与えられたリテラルからデータ型を型推論し、その結果でTのデータ型を決定します。この例では、int_rangeはRange<i32>、float_rangeはRange<f64>となります。
(3)は、コメントアウトされていますが、コメントを削除するとコンパイルエラーとなります。それは、初期化に用いているリテラルのデータ型が一致しないからです。なお、(1)の文で型を明示する場合は、以下のように記述します。この辺りも、通常の変数宣言のルール通りです。もちろん、型パラメーターにi32型を指定しているので、浮動小数点数などを指定するとコンパイルエラーとなります。
let int_range: Range<i32> = Range {min: 1, max: 10, step: 1, current: -1};
ジェネリクス型の構造体にメソッドを実装する
構造体をジェネリクス型として定義したので、それにメソッドを実装してみます。Vec<T>型やHashMap<K, V>型にもあったnewメソッドを、最小値、最大値、増減値、現在値を受け取って初期化するとして実装します。ジェネリクス型におけるメソッドの実装も、impl文(第7回)を使うのは変わりません。以下は、上記のgeneric_struct.rsにnewメソッドを追加した例です。
…略… impl<T> Range<T> { (1) fn new(min: T, max: T, step: T, current: T) -> Self { (2) Range {min, max, step, current} (3) } } fn main() { let int_range = Range::new(1, 10, 1, 5); (4) let float_range = Range::new(1.0, 10.0, 0.1, 0.0); …略… }
(1)では、Range構造体へメソッドを実装しています。通常のメソッド実装と異なるのは、以下の点です。
- 構造体がジェネリクス型になっているのでRange<T>を指定する
- implキーワード自身にも型パラメーターを付記する
(2)は、newメソッドの定義です。ここでも、仮引数に型パラメーターであるTを指定している他は、通常のメソッド定義と同様です。なお、戻り値がSelfとなっているので、newメソッドは自分自身のインスタンスを返す、すなわちコンストラクタとして動作します。(3)は、そのインスタンスを生成している部分です。ここが値として評価され、newメソッドの戻り値として返ります。引数とフィールドの名前が同じなので、省略記法が使えることに注意してください。
(4)からの2行は、newメソッドによってインスタンスを生成し、変数を初期化しています。ここでも型推論が働き、型パラメーターTのデータ型が自動的に決められる他、型の不一致があればコンパイルエラーとなります。また、データ型を明示して変数を宣言できることも同様です。
【補足】関数とジェネリクス
メソッドでない(構造体に従属しない)、普通の関数でもジェネリクスは可能です。以下は、平均値を求める関数です。関数名に型パラメーターを付記することで、引数、戻り値をはじめ関数内部でジェネリクス型を使用できます。
fn avarage<T>(list: &[T]) -> T { … }
さらにメソッドを実装する
もうひとつ、メソッドを実装してみます。現在値を増やすメソッドforwardは、現在値のフィールドcurrentに増減値のフィールドstepの値を加えます。以下は、上記のsrc/bin/generic_method.rsにforwardメソッドを追加した例です。
…略… impl<T> Range<T> { …略… fn forward(&mut self) { (1) self.current += self.step; } } fn main() { …略… int_range.forward(); float_range.forward(); …略… }
(1)が、forwardメソッドの定義です。メソッドの中身では、特別なことはしていません。self.currentでインスタンスのcurrentフィールドを参照し、「+=」演算子でインスタンスのstepフィールドであるself.stepを加えているだけです。一見、何の問題もなさそうな文ですが、コンパイルエラーとなります。
error[E0368]: binary assignment operation `+=` cannot be applied to type `T` (1) --> src/bin/generic_method2.rs:14:9 | 14 | self.current += self.step; | ------------^^^^^^^^^^^^^ | | | cannot use `+=` on type `T` | help: consider restricting type parameter `T` (2) | 9 | impl<T: std::ops::AddAssign> Range<T> { (3) | ^^^^^^^^^^^^^^^^^^^^^
(1)は、二項演算子「+=」は型Tに適用できない、という意味です。考えてみれば当たり前の話で、あらゆる型を表す可能性のあるTに対して、演算子「+=」がいつも適用できるとは限りません。それ以前に、「+=」演算子の動作はTの実際の型によって異なるはずで、それは定義だけを見ても分かりません。
(2)は、この解決策を示しています。「型パラメーターTをもっと厳密にしたら?」と指摘されています。具体的なコードが(3)です。よく見ると、impl文に付記した型パラメーターに「std::ops::AddAssign」が追加されています。これはいったい何なのでしょう? 次に紹介するトレイトで、この謎を解いていきます。
トレイトを使う
トレイトという用語は、これまでの回でも頻繁に登場しました。第5回のCopyトレイトとCloneトレイト、第8回のDisplayトレイト、第10回のEqトレイトとHashトレイトなどです。
トレイトとは?
トレイトとは、「特性」とか「特質」といった意味です。Rustでは、複数の型で利用目的や呼び出し方法が共通である関数があるとき、それらはトレイトとしてまとめることができます。
例えばCopyトレイトとは、複製(copy)に関する関数をとりまとめています。Copyトレイトを実装する型では、代入においてバイト列を複製します。スカラー型はこのCopyトレイトで値を複製します。String型をはじめとするほとんどの型は、Copyトレイトを実装していないので値は複製されません。これが、String型における代入はコピーではなくムーブになる理由です。
上記でエラーとなった際に解決策として提示された、std::ops::AddAssignもトレイトです。std::opsは名前空間なので省きますが、AddAssignトレイトはその名前から想像が付くように、加算代入演算子「+=」に必要とされるメソッドを実装するトレイトです。このトレイトを型パラメーターに加えた理由は後ほど紹介します。
【補足】トレイトとインタフェース
トレイトの考え方は、Javaなどのインタフェースに近いものです。あるインタフェースを実装したクラスが複数あるとき、それぞれのクラスにおいてインタフェースが備えるメソッドの呼び出し方法(利用方法)は共通です。トレイトでも同様に、あるトレイトを実装した構造体が複数あるとき、それぞれの構造体においてトレイトの備えるメソッドの利用方法は共通です。インタフェースに理解のある方は、この先もインタフェース(や抽象メソッド)をイメージして読み進めてみてください。
トレイトを定義する
「トレイトはインタフェースのようなものである」と紹介したことから想像できるように、トレイトも構造体と同じような構文でtrait文を使って定義できます。以下は、面積をつかさどるAreaというトレイトに、面積を計算するcalcというメソッドを定義する例です。
trait Area { fn calc(&self) -> f64; }
構造体の定義に似ていますが、以下の点が異なります。
- traitキーワードを使う
- メソッドの定義がトレイトの定義の内部に含まれる
しかも、メソッドはシグネチャのみであり、メソッドの実体が記述されていません。トレイトを実装する構造体で、個別に実体を記述する必要があります。
トレイトを構造体に実装する
定義したトレイトは、構造体に実装できます。トレイトの構造体への実装は、impl〜for文を用います。三角形を表すTriangle構造体と、台形を表すTrapezoid構造体を定義し、それぞれがAreaトレイトを実装した例が、以下です。
// 三角形の構造体 struct Triangle { (1) base: f64, height: f64, } // 台形の構造体 struct Trapezoid { (2) top_base: f64, bottom_base: f64, height: f64, } // AreaトレイトのTriangle構造体への実装 impl Area for Triangle { (3) fn calc(&self) -> f64 { (self.base * self.height) / 2.0 } } // AreaトレイトのTrapezoid構造体への実装 impl Area for Trapezoid { (4) fn calc(&self) -> f64 { (self.top_base + self.bottom_base) * self.height / 2.0 } }
(1)と(2)は、それぞれ構造体の定義です。(3)と(4)で、それぞれトレイトを実装し、さらにメソッドの実体を実装しています。名前は同じくcalc()ですが、それぞれの構造体で中身は別物です。また、トレイトを実装しますが、構造体の名前自体は変化しない(つまり、別の型を作り出すわけではない)ことにも注意してください。
【補足】メソッドの既定値
ここでは、トレイトの定義時には処理内容を省略しましたが、記述した場合にはメソッドの既定の実装になります。既定の実装を持つメソッドは、トレイトを実装した構造体の側でそれをそのまま使うこともできますし、処理内容を上書きする(オーバーライドする)ことができます。なお、オーバーライドしたメソッドの側から、既定の実装を持つメソッドを呼び出すことはできません。
トレイトを引数に与える
トレイトは、メソッド(関数)の引数に与えることもできます。正確には、トレイトを実装したデータ型の値を引数に与えることができるということです。トレイトを受け取ったメソッドは、そのトレイトを実装したあらゆるデータ型を受け付けます。よって、共通した機能を持った型を要求する場合に、型ごとに関数を用意する必要がありません。
トレイトをメソッドに渡すには、仮引数のデータ型にimplキーワードとともにトレイトを指定します。以下は、Areaトレイトを受け取ってその面積を表示する関数の例です。
…略… // Areaトレイトを受け取ってcalc()メソッドを実行する関数 fn display_area(area: &impl Area) { (1) println!("Area: {}", area.calc()); } fn main() { let triangle = Triangle {base: 10.0, height: 20.0}; let trapezoid = Trapezoid {top_base: 10.0, bottom_base: 20.0, height: 10.0}; display_area(&triangle); (2) // Area: 100 display_area(&trapezoid); // Area: 150 }
(1)は、display_areaという引数にAreaトレイトの参照を受け取る関数を定義しています。関数内部では、Areaトレイトのcalc()メソッドを呼び出し、面積値を表示しています。既述の通り、引数として実際に受け取るのは、Areaトレイトを実装したあらゆるデータ型です。
(2)は、display_area関数を実際に呼び出しています。三角形のtriangle、台形のtrapezoidをそれぞれ渡しているので、実際に呼び出されるcalc()メソッドも、それぞれが実装したものです。
【補足】トレイトを戻り値とする
トレイトは引数にすることができますが、戻り値とすることもできます。この場合も、実際には「トレイトを実装するあらゆるデータ型」が戻り値です。具体的には、メソッド(関数)の定義において、戻り値の型にimplキーワードを付加するだけです。
fn get_right_triangle(&self) -> impl Area { Triangle {base: 10.0, height: 10.0} }
トレイト境界
最後に、ジェネリクス型の例で用いたsrc/bin/generic_method2.rsのコンパイルエラーを除去する過程を通じて、トレイト境界(Trait bound)を紹介します。すでにエラーメッセージ中のヒントで示されていましたが、impl文の型パラメーターには、トレイト境界を付記できます。
ここでいうトレイト境界とは、型パラメーターTに対して「このトレイトを実装しているものだけを認める」という制限を与えるものです。一定の線引きをするので、境界というわけです。以下は、src/bin/generic_method2.rsにトレイト境界を指定した例です。
…略… impl<T: std::ops::AddAssign> Range<T> { (1) …略… fn forward(&mut self) { self.current += self.step; } }
変更は1カ所だけ、(1)のみです。型パラメーターTの後に、トレイトであるstd::ops::AddAssignを続けています。これによって、トレイトstd::ops::AddAssignを実装したTだけを認める、という意味になります。具体的には、i32などのスカラー型が該当します。ところが、上記のsrc/bin/generic_method3.rsをコンパイルすると、今度は別のエラーが出ます。
error[E0507]: cannot move out of `self.step` which is behind a mutable reference --> src/bin/generic_method3.rs:13:25 | 13 | self.current += self.step; | ^^^^^^^^^ move occurs because `self.step` has type `T`, which does not implement the `Copy` trait
「ムーブになる、それはTがCopyトレイトを実装していないから」というエラーです。Tの型が何であるか分からないので、Copyトレイトを実装していないと見なすのです。これに対応するには、「+」演算子を用いてトレイト境界をさらに追加します。
…略… impl<T: std::ops::AddAssign + Copy> Range<T> { (1) …略… fn forward(&mut self) { self.current += self.step; } }
これによって、トレイト境界がさらに厳しくなり、std::ops::AddAssignトレイトとCopyトレイトを実装する型しか、Range<T>型を使えなくなりました。事実上、スカラー型しかRange<T>型を使えませんが、Range<T>型の目的からいえば、理にかなっています。これで、ジェネリクス型を独自に定義し、メソッドを実装するという目的が達成できました。
まとめ
今回は、ジェネリクスのまとめとして、ジェネリクス型を独自に定義し、それと切り離せないトレイトを理解しながら、メソッドを実装してみました。トレイトという一見なじみにくい概念も、すでにある仕組みと対照させると、理解しやすいでしょう。
次回は、モジュールシステムを紹介します。
筆者紹介
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・Twitter: @yyamada(https://twitter.com/yyamada)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.