💊

【Go&Rust】インターフェース(トレイト)を使用する上での違いとして考えていること

2024/11/03に公開

こんにちは。
私は最近業務ではGoを書いていて、プライベートではRustを書くことが多いです。
今回は、これらの言語における様々な違いの中でも、インターフェース(Rustではトレイト)に焦点を当て、個人的に印象深い点をまとめます。

前提(それぞれの実装例)

まずは、動物を例に用いて、両者を使った簡単な実装例を以下に示します。

Go

type Animal interface {
    Speak() string
}

type Dog struct {}
func (d *Dog) Speak() string {
    return "Woof!"
}

type Swallow struct {}
func (s *Swallow) Speak() string {
	return "Tweet!"
}

Rust

pub trait Animal {
    fn speak(&self) -> String;
}

pub struct Dog;
impl Animal for Dog {
    fn speak(&self) -> String {
        "Woof!".to_string()
    }
}

pub struct Swallow;
impl Animal for Swallow {
    fn speak(&self) -> String {
        "Tweet!".to_string()
    }
}

当然、記述方法自体にも両者で違いはありますが、個人的に使い方の違いとして、実装する際に注意している点を以降で書きます。

Goのインターフェースは利用者側で必要な範囲で定義するもの、Rustのトレイトは提供する側で共通の振る舞いを提示するもの

Goのインターフェースは利用者側で必要な範囲で定義するもの

これについては、「Go言語 100Tips ありがちなミスを把握し、実装を最適化する」でも紹介されている内容です。クライアント側でインターフェースを用意することで、不要な抽象化をしないとのことです。

https://book.impress.co.jp/books/1122101133

これについては、具体的な実装観点では、Goではインターフェースを実装する際に実装する先のインターフェースを明示しない点が要点であるように思います。上記で実装例を示したように、Rustでトレイトを実装する際には、impl Animal for Dogといったように実装するインターフェース(トレイト)を明示しますが、Goではこれがありません。

これが何を意味するかわかるように、試しに利用者側ではなく、提供する側でインターフェースを定義してみます。先程のGoの実装例におけるAnimalインターフェースにメソッドを追加してみました。

提供する側にインターフェースを用意.go
type Animal interface {
	Speak() string
	Fly() uint
}

type Swallow struct{}
func (s *Swallow) Speak() string {
	return "Tweet!"
}

func (s *Swallow) Fly() uint {
	println("Swallow is flying!")
	return 100
}

type Dog struct{}
func (d *Dog) Speak() string {
	return "Woof!"
}

// DogはFlyを実装できない!

このように実装側(提供する側)にAnimalインターフェースを定義して、それをどんどん拡張して汎用化していった結果、上記のように困ってしまう場合があります。犬は残念ながら飛べません。
ただし、Goの場合、以下のように実装することができます。

実装.go
type Dog struct{}
func (d *Dog) Speak() string {
    return "Woof!"
}

type Swallow struct{}
func (s *Swallow) Speak() string {
    return "Tweet!"
}

func (s *Swallow) Swallow() uint {
    println("Swallow is flying!")
    return 100
}
動物の鳴き声だけを扱う.go
type Animal interface {
    Speak() string
}

// GetAnimalSoundは、動物の鳴き声を返却します。
func GetAnimalSound(animal Animal) string {
    return animal.Speak()
}
鳥類として鳴き声と飛行能力を扱う.go
type Bird interface {
    Speak() string
    Fly() uint
}

// GetAnimalSoundWithFlyは、鳥類の鳴き声と飛行距離を返却します。
func GetAnimalSoundWithFly(animal Bird) (string, uint) {
    return animal.Speak(), animal.Fly()
}

このように、インターフェースを利用する側で、使いたいメソッドだけを切り取って定義することで、責任範囲が適切に分割されつつも柔軟な設計が可能になっています。
これが、「Goのインターフェースは利用者側で必要な範囲で定義するもの」ということだし、Goのインターフェースの使用におけるメリットと捉えています。

Rustのトレイトは提供する側で共通の振る舞いを提示するもの

結論、Rustのトレイトとその実装において、上記のGoで挙げたような柔軟さにはやや劣る面があると考えています。ただし、これは単にデメリットではなくて、逆に言えばRustの厳格な型システムや所有権の概念と結びつけた明示的な実装が可能になるとも言えます。

pub trait Animal {
    fn speak(&self) -> String;
    fn walk(&self);
    fn eat(&self) -> String {
        "I'm eating.".to_string()
    }
}

pub struct Dog;
impl Animal for Dog {
    fn speak(&self) -> String {
        "Woof!".to_string()
    }

    fn walk(&self) {
        println!("I'm walking.");
    }

    fn eat(&self) -> String {
        "I'm eating dog food.".to_string()
    }
}

上記のように、DogAnimalトレイトを実装するということが明示的です。そして、実装する側は振る舞い(メソッド)が制御されているため、型の使い方が規定され、意図した範囲でのみ利用されるようになりやすいです。eatのようにトレイトにデフォルト実装を定義することが可能な点もこの考え方に沿っていると感じます。

さらにはこれによって、Rustのトレイトでは静的ディスパッチが使用可能で、コンパイル時に型が確定することで実行時のパフォーマンスが向上するメリットもあります。(Goでは基本的に動的ディスパッチになる)

https://doc.rust-lang.org/book/ch17-02-trait-objects.html?highlight=dispatch#trait-objects-perform-dynamic-dispatch

また、Rustでも柔軟な抽象化を実現する手法として、トレイトがトレイトを継承するといったことも可能です。

pub trait Bird: Animal {
    fn fly(&self) -> u32;
}

おわりに

GoのインターフェースとRustのトレイトについて、実装時に個人的に意識している点を書きました。
他にもGoとRustで、インターフェース(トレイト)におけるジェネリクスの使用法などで違いや意識している点もあるので、また今度まとめてみたいと思います。

Discussion