Scala の trait と Rust の trait は微妙に使い方が異なる、とよく質問を受けます。たしかに、使い心地は微妙に異なるかもしれません。Scala はオブジェクト指向を中心に設計された言語ですが、Rust はそれを中心に設計されているとは言えません*1。こういった言語設計の差が、trait の使い心地の違いを生み出していると私は思っています。
両者の trait には、共通した特徴もあります。共通した処理をまとめあげるという意味では同じ目的をもっているといえますし、また、「犬は動物である」「猫は動物である」の共通性を示すことで、共通したものをひとまとめに処理しきることもまた可能です。
Scala には implicit
という強力な機能が存在します。これは柔軟でスケーラブルなソフトウェアデザインを可能にする Scala の特徴のひとつです。非常にすばらしい機能です。この機能を利用すると、型に応じて実装を切り替えたり、あるいは既存の型に対して機能を追加できたりします。これは、Rust においては trait
という概念ひとつに集約されています。ここがおそらく、もともと Rust を触っていた人が Scala を触った際に抱く違いのひとつなのでしょう。
今回は、Scala のエンジニアが Rust に取り組んだ場合、あるいは Rust のエンジニアが Scala に取り組んだ場合によく耳にするこの話について、私の理解の範囲で Scala と Rust の実装上の関係性を少し記しておきたいと思います。ひとつ注意点ですが、あくまで直感的な理解を目的としているので、実装上はこうなるという話のみを記しています。
Scala と Rust は、言語デザインも目的も大きく異なる言語です。たとえば、Scala は JVM 上で動作しそうさせることを目的として最適化された言語なので、裏側が LLVM の Rust とはその時点で設計がまったく異なります。単純比較すること自体、実はナンセンスかもしれません。なのでそういった意味でも、今回は直感的な理解に重きを置くことにします。
型の理論の関係上厳密にはどうしても異なるという話がありましたら、こっそり Twitter などで教えて下さい。
共通する振る舞い
今回見ていきたいのは、次のような挙動についてです。
- インタフェースで
Animal
というものを定義する。
- その具象型として
Dog
と Cat
を定義する。
Dog
と Cat
は Animal
という集合に属する存在なのだから、List<Animal>
というひとつの型に押し込められる。
- また、
Animal
とは全然別の Machine
というインタフェースを定義する。具象型として Robot
を定義する。
Animal
を要求している関数に Machine に属する Robot を入れようとしても、ちゃんとコンパイルエラーになる。
同じカテゴリのものを同じように扱えるようにする
よくある「犬は動物である」「猫は動物である」を双方の言語で表現しましょう。これを表現するためには、インターフェースで動物を用意し、各種類の動物を具象型で実装すればよいです。Scala ではすぐに実現可能ですが、Rust ではひと手間必要になります。
Scala では次のようになります。
trait Animal {
def name: String
def bark(): Unit
}
class Dog(override val name: String) extends Animal {
override def bark(): Unit = println(s"$name: ワンワン")
}
class Cat(override val name: String) extends Animal {
override def bark(): Unit = println(s"$name: ニャーニャー")
}
Dog
や Cat
は、Animal
という型と関係性をもつことになるため、次のように List に追加したとしても、コンパイルエラーにはならないはずです。
val petShop: List[Animal] =
List(new Dog("ポチ"), new Dog("マル"), new Cat("太郎"), new Cat("花子"))
petShop.foreach(_.bark())
これを部分的型付け(サブタイピング)と呼びます。interface と実装の関係性以外に、Java や Scala ではクラス同士の継承関係を利用しても同様の結果を得られます。
Rust で実装する
ただし、Rust ではそのままではうまくいきません*2。Rust では、下記はコンパイルエラーになります。
fn main() {
let pet_shop: Vec<Animal> = vec![
Dog {
name: "ポチ".to_string(),
},
Dog {
name: "マル".to_string(),
},
Cat {
name: "太郎".to_string(),
},
Cat {
name: "花子".to_string(),
},
];
pet_shop.into_iter().for_each(|a| a.bark());
}
trait Animal {
fn bark(&self);
}
struct Dog {
name: String,
}
impl Animal for Dog {
fn bark(&self) {
println!("{}: ワンワン", self.name);
}
}
struct Cat {
name: String,
}
impl Animal for Cat {
fn bark(&self) {
println!("{}: ニャーニャー", self.name);
}
}
❯❯❯ cargo check
Checking pol-prac v0.1.0
warning: trait objects without an explicit `dyn` are deprecated
--> src/main.rs:2:23
|
2 | let pet_shop: Vec<Animal> = vec![
| ^^^^^^ help: use `dyn`: `dyn Animal`
|
= note: `#[warn(bare_trait_objects)]` on by default
error[E0277]: the size for values of type `dyn Animal` cannot be known at compilation time
--> src/main.rs:2:19
|
2 | let pet_shop: Vec<Animal> = vec![
| ^^^^^^^^^^^ doesn't have a size known at compile-time
|
= help: the trait `std::marker::Sized` is not implemented for `dyn Animal`
= note: to learn more, visit <https://doc.rust-lang.org/book/ch19-04-advanced-types.html#dynamically-sized-types-and-the-sized-trait>
= note: required by `std::vec::Vec`
(...)
Animal
を dyn trait
にして、さらに Sized トレイトを実装するとコンパイルが通るようです。要するに、コンパイラに「メモリサイズを教えてくれ」といわれています*3。なるほど、メモリのサイズがわからないんですね。こういうときは、Box が使えます。dyn trait にして、ヒープに寄せてしまいましょう。
fn main() {
let pet_shop: Vec<Box<dyn Animal>> = vec![
Box::new(Dog {
name: "ポチ".to_string(),
}),
Box::new(Dog {
name: "マル".to_string(),
}),
Box::new(Cat {
name: "太郎".to_string(),
}),
Box::new(Cat {
name: "花子".to_string(),
}),
];
pet_shop.into_iter().for_each(|a| a.bark());
}
trait Animal {
fn bark(&self);
}
struct Dog {
name: String,
}
impl Animal for Dog {
fn bark(&self) {
println!("{}: ワンワン", self.name);
}
}
struct Cat {
name: String,
}
impl Animal for Cat {
fn bark(&self) {
println!("{}: ニャーニャー", self.name);
}
}
これならば、コンパイルは通ります。Scala 側と得られる出力結果は同じになります。
重要なことは、Rust の trait においては、Scala では求められなかったメモリサイズに関する情報を求められるということです。これは、Rust がシステムプログラミング言語であり、ハイパフォーマンス性を保つために多くの物事を静的に解決したい言語であるという点に依拠しています。言語デザインや言語の目的の差からやってくる違いと言えるでしょう。
違うカテゴリのものはちゃんと弾く
さて、両者の言語に共通する trait の使い方として、トレイトに対して境界を設定できるという点があります。
たとえば、Scala で次のような関数を書いたとしてみましょう。
object animal {
def feed[A <: Animal](animal: A): Unit = {
animal.bark()
}
}
A <: Animal
は、A
は Animal
を上限境界とするという意味です。
さらに、わかりやすさのために、Animal
でない別の種類の存在 Machine
を追加しましょう。
trait Machine {
def name: String
}
class Robot(override val name: String) extends Machine
実際にこれらを使ってみましょう。
val pochi = new Dog("ポチ")
val taro = new Cat("太郎")
val atom = new Robot("アトム")
animal.feed(pochi)
animal.feed(taro)
animal.feed(atom)
pochi と taro は Animal に属する Dog または Cat なので、feed 関数の実引数として使用可能です。しかし、atom は Animal に属さない(Robot という trait に属する)型なので、feed 関数の実引数としては型変数の制約を満たさないために使用不可能です。
Rust で実装する
Rust でも似たようなことは表現できて、
fn feed<T: Animal>(animal: T) {
animal.bark()
}
trait Machine {}
struct Robot {
name: String,
}
fn main() {
let dog = Dog { name: "ポチ".to_string() };
let cat = Cat { name: "太郎".to_string() };
let atom = Robot { name: "アトム".to_string() };
feed(dog);
feed(cat);
feed(atom);
}
同様にコンパイルエラーです。トレイト境界を満たさないため、関数の引数としては使用できませんといった主旨のコンパイルエラーが検出されます。
~/dev/rust/pol-prac via 🦀 v1.41.1
❯❯❯ cargo check
Checking pol-prac v0.1.0
error[E0277]: the trait bound `Robot: Animal` is not satisfied
--> src/main.rs:8:10
|
8 | feed(atom); // Compile Error!
| ^^^^ the trait `Animal` is not implemented for `Robot`
...
11 | fn feed<T: Animal>(animal: T) {
| ---- ------ required by this bound in `feed`
error: aborting due to previous error
たしかに、似たようなことはでき、得られる結果は同じになりました*4。
implicit と trait
さて、ここまで長々と説明してきてしまいましたが、ここから implicit がはじめて登場します。Scala の implicit は、次の2つの役割を現在では多く利用しているといっていいでしょう。
- ある型に対して機能を追加するものとして利用する(Enrich My Library などと呼ばれています)。
- 型クラス*5として利用する(implicit parameter として Scala には登場します)。
Enrich My Library
Enrich My Library というのは Scala でよく使われる手法で、要するに何かある型があったとして、その型にあとから機能を追加するという手法です。
今回確認したいのは下記のような挙動についてです。
- Int なり i32 なり、標準で入っている数値型に対して機能を拡張する。
適切な例がすぐに思い浮かばなかったので、こちらの記事に掲載されているコードを引用させていただきます。与えられた回数分 A
という文字列を標準出力します。
object EnrichMyLibrary extends App {
implicit class RichInteger(self: Int) {
def times(block: => Unit): Unit = {
var n = 0
while(n < self) {
block
n += 1
}
}
}
3.times {
print("A")
}
}
Scala では、このように Int のような特定の型に対して、後付けで機能を追加できる方法があります。拡張メソッドとも呼ばれるようです。3.times
というように、3
という Int
型には、従来 times
というメソッドは存在しないのですが、新たにメソッドを外から追加しています。
Rust で実装する
少し強引かもしれませんが、Rust では仮に実装し直すとすると、次のような方法が1つ考えられます。
fn main() {
3.times(|num| {
let mut n = 0;
while n < num {
print!("A");
n += 1;
}
});
}
trait RichInteger<T: Sized> {
fn times<F>(self, block: F) where F: FnOnce(T) -> ();
}
impl RichInteger<i32> for i32 {
fn times<F>(self, block: F) where F: FnOnce(i32) -> () {
block(self)
}
}
Rust でもやはり、i32
というもともとある型には times
という関数は存在しません。しかし、trait
を用いて実装を用意してあげることによって、外から機能を追加することができました。
重要なことは、Rust では trait
を使うと Scala でいうところの Enrich My Library を実現でき、Scala では implicit
を用いるとそれを実現することができるという点です。
implicit parameter
Scala の implicit のもうひとつの重要な機能である implicit parameter もエミュレートしていきましょう。今回確認したい挙動は下記です。
- 「モノイド」を定義できること。
- 定義したモノイドを用いて、リストの畳み込み*6を実装できること。
- 型に応じて呼ばれるモノイドが暗黙的に切り替わること。
モノイド
ここでひとつ Scala や Rust そのものとは関係のない用語を投入させてください。モノイドという用語をこれから使っていきます。これは型クラスの説明の際に使用されることの多い概念のひとつで、もっとも理解の助けになるため使用します。
モノイドというのは、もともとは数学に存在する概念で、次の条件(モノイド則といいます)を満たすものをモノイドと呼んでいます*7。
- 結合律を満たす: たとえば、演算
+
について、(a+b)+c = a+(b+c)
が成立しますよね。
- 単位元を持つ: たとえば、演算
+
の単位元は 1 + 0 = 0 + 1 より、0であることがわかります。直感的な理解をするならば、ある元 a
に対して e
という別の元を与えた際に、a
に対して e
は一切影響を与えない存在と言えるでしょう。
さしあたっては、「いい感じに2つの物事を足し算させるために必要な概念」だと思ってください。
モノイドは、1あるいは2の両方を満たしさえすれば、どのようなデータ型に対しても適用可能な概念です。つまり、Int 型であっても String 型であっても、1と2のルールを満たすように実装すれば、それはモノイドにできるということです。
そして、型に応じた実装さえ切り替えれば、使用する側は1つのシグネチャで済むようになってきます。コードの抽象化をさらに一段押し進めることができるようになるのです。これを Scala なら implicit、Rust なら trait を使って表現可能です。
モノイドを用意しましょう。op
が結合律を満たす関数で、unit
が単位元を満たす関数です。
trait Monoid[A] {
def op(lhs: A, rhs: A): A
def unit: A
}
今回は、Int
型と String
型についてのモノイドを定義します。モノイド則を満たしていきましょう。
object IntMonoid extends Monoid[Int] {
override def op(lhs: Int, rhs: Int): Int = lhs + rhs
override def unit: Int = 0
}
object StringMonoid extends Monoid[String] {
override def op(lhs: String, rhs: String): String = lhs + rhs
override def unit: String = ""
}
さてここで、モノイドを上手に使うためのちょっとしたテクニックを使います。implicit parameter という存在です。先に、書きたい関数のシグネチャを示してみます。
object list {
def sum[A](list: Seq[A])(implicit monoid: Monoid[A]): A =
list.foldLeft(monoid.unit)(monoid.op)
}
このようにモノイドを活用して、リストの畳込み処理を行っていきたいと思っています。implicit monoid: Monoid[A]
とありますね。これが implicit parameter です。
implicit parameter に該当の型に対するモノイドの実装を与えられるように、implicit の定義を行います。
object monoids {
implicit val intMonoid: Monoid[Int] = IntMonoid
implicit val stringMonoid: Monoid[String] = StringMonoid
}
monoids
を import しさえすれば、あとは implicit parameter は型を解決してよしなに必要な方を使用してくれます。
では、使用する側を定義してみましょう。
object MonoidMain extends App {
import monoids._
val intList = Seq(1, 2, 3)
list.sum(intList)
val stringList = Seq("a", "b", "c")
list.sum(stringList)
}
list.sum
という関数は、引数が Seq[Int]
なり Seq[String]
ではあるものの、内部で必要な Monoid の呼び出しが implicit parameter の解決によって切り替えられます。これにより、使用者は実装の細かい切り替えを気にすることなく、ただ monoids
の中身を import しておきさえすればよいというものです。
次は、これを Rust で表現してみましょう。
Rust で実装する
まず最初は、Scala 側の implicit parameter のパターンにそろえて実装をしてみます。
モノイドの型クラスを定義しましょう。
trait Monoid<T> {
fn op(&self, lhs: T, rhs: T) -> T;
fn unit(&self) -> T;
}
たとえば、i32
という型をモノイドにしたいとします。Scala と同じように、一旦実体化する必要があるので、次のような構造体を用意します。
struct I32Monoid;
この構造体に対して、op
と unit
の内容を実装します。
impl Monoid<i32> for I32Monoid {
fn op(&self, lhs: i32, rhs: i32) -> i32 {
lhs + rhs
}
fn unit(&self) -> i32 {
0
}
}
比較のために、String
型に対しても同様にモノイドを用意しておきます。
struct StringMonoid;
impl Monoid<String> for StringMonoid {
fn op(&self, lhs: String, rhs: String) -> String {
lhs + &rhs
}
fn unit(&self) -> String {
"".to_string()
}
}
リストの畳込みを行い、最終的な結果を得るための関数を用意します。ポイントは、i32
型でも String
型でも受付可能なように、T
型を使用している点です。これによって、どちらの型についてのモノイドがやってきたとしても、中でリストの畳み込みを行うことができます。
fn sum<T>(list: Vec<T>, monoid: impl Monoid<T>) -> T {
list.into_iter().fold(monoid.unit(), |acc, n| monoid.op(acc, n))
}
では、これを使用する側を用意してみましょう。
fn main() {
let int_result = sum(vec![1, 2, 3], I32Monoid);
println!("{}", int_result);
let string_result = sum(
vec!["a".to_string(), "b".to_string(), "c".to_string()],
StringMonoid,
);
println!("{}", string_result);
}
結果は、i32 に対する演算は 6
になり、String に対する演算は "abc"
となります。
ただし、Scala 側にあった「使用者はモノイド演算の切り替えを考慮せずにただ使うだけでいい」というよさがなくなってしまっています。
さらに、sum
の引数に毎度 Monoid<T>
を代入する必要があり、補助計算向けのコンテキストとリスト本体のコンテキストを同時に引数として引き回すことになります。Scala の implicit parameter はその点、処理本体のコンテキストのみを関数の引数として暗黙的に受け取ればいいです。概念(あるいは抽象度、カテゴリ)の異なるもの同士の実装の切り離しという観点で見たとき、異なる概念が一度に引き回されることがなく、スッキリした見た目になるように感じます*8。
Rust には implicit parameter がありません。でも、実装をスッキリさせたい。どうすれば…。
trait をもう少し活用しましょう。Rust では、先ほど見たように i32
という型に対して直接関数を生やすことができていましたね。これを使えば、実装をもう少しスッキリさせることができます。実際にやってみましょう!
あらためて Monoid<T>
を定義し直します。ポイントは、unit
という関数を static にしておくことです。
trait Monoid<T> {
fn op(&self, rhs: T) -> T;
fn unit() -> T;
}
i32 型のモノイド則を満たしていきましょう。
impl Monoid<i32> for i32 {
fn op(&self, rhs: i32) -> i32 {
self + rhs
}
fn unit() -> i32 {
0
}
}
impl Monoid<String> for String {
fn op(&self, rhs: String) -> String {
self.to_string() + &rhs
}
fn unit() -> String {
"".to_string()
}
}
リストを畳み込む関数は、少し様子が変わってきます。先ほどの引数には impl Monoid<T>
が存在していましたが、今回は必要ありません。なぜなら、trait がもはや、自身の型の内容を利用して処理を継続できるように実装されているためです。
fn sum<T: Monoid<T>>(list: Vec<T>) -> T {
list.into_iter()
.fold(T::unit(), |acc, n| acc.op(n))
}
unit
を static にしておいたのが、ここで生きてきました。T
型は Monoid<T>
を実装していることを前提としています。その前提のもと T
のリストを受け取り、内部で畳み込みを行います。これで畳み込み処理は実装完了です。使用する側の実装も少し変更してみましょう。
fn main() {
let int_result = sum(vec![1, 2, 3]);
println!("{}", int_result);
let string_result = sum(
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
println!("{}", string_result);
}
先ほどまであった I32Monoid
や StringMonoid
はなくなりました。結果も 6
と "abc"
を得られ同等でした。
Scala の例と同じように、使う側ではまったくどの処理を呼ぶかについて意識していません。ただ、sum
関数を呼んでいるだけです。sum
関数の内部で適用される trait が切り替えられ、それに応じたモノイドの演算が行われるだけです。Scala 側でできていたことは、やはり Rust の trait によって実現可能なのでした。
余談ですが、Scala の implicit parameter は下記のようにも記述可能です。
object list {
def sum[A: Monoid](list: Seq[A]): A =
list.foldLeft(implicitly[Monoid[A]].unit)(implicitly[Monoid[A]].op)
}
こうすると、Rust 側で定義した関数のシグネチャと同等になりますね。
まとめ
実は書いている本人も結局 Scala の implicit と Rust の trait の関係性を一言でズバッと言い表せずにモヤモヤしています。Scala の trait と Rust の trait は、もちろん同等の機能を持っている面もあります。
それは、最初に示した Java の interface のような trait の使い方を通して学びました。
一方で、Rust の trait は Scala の trait 以上の機能をもっています。Scala においては implicit を用いて実現されていた機能が、実は Rust では trait ひとつで実現可能なのでした。
ところで、今回は意図的にパラメトリック多相やアドホック多相、型クラスという言葉をあえて避けて通ってきました。実装の結果を通じて結果が等価であることを確認しながら進んできましたが、裏側にはこういった概念が見え隠れします。下記の記事などが参考になると思いますので、裏側の概念が気になる方はぜひご参照ください。
また、Scala の implicit については下記の記事を大いに参考にさせていただきました。