高度な Rust トレイトの探究

RustRustBeginner
オンラインで実践に進む

This tutorial is from open-source community. Access the source code

💡 このチュートリアルは英語版からAIによって翻訳されています。原文を確認するには、 ここをクリックしてください

はじめに

Advanced Traitsへようこそ。この実験は、Rust Bookの一部です。LabEx で Rust のスキルを練習することができます。

この実験では、Rust をより深く理解した今、以前「Traits: Defining Shared Behavior」で扱ったトレイトのより高度な詳細について掘り下げます。

Advanced Traits

私たちは最初に「Traits: Defining Shared Behavior」でトレイトについて学びましたが、より高度な詳細については議論しませんでした。今、あなたは Rust についてもっと知っているので、細かいところに入ることができます。

関連型

関連型は、型のプレースホルダをトレイトと結び付けるもので、トレイトメソッドの定義ではこれらのプレースホルダ型をシグネチャに使用できます。トレイトの実装者は、特定の実装においてプレースホルダ型の代わりに使用する具体的な型を指定します。このようにして、トレイトが実装されるまで、それがどのような型であるかを正確に知る必要なく、いくつかの型を使用するトレイトを定義することができます。

この章で説明したほとんどの高度な機能は、ほとんど必要ないと説明してきました。関連型は中間的な位置にあります:この本の残りの部分で説明される機能よりも頻繁には使用されませんが、この章で議論される他の多くの機能よりも一般的に使用されます。

関連型を持つトレイトの 1 つの例は、標準ライブラリが提供するIteratorトレイトです。関連型はItemと呼ばれ、Iteratorトレイトを実装する型が反復処理する値の型を表します。Iteratorトレイトの定義は、リスト 19-12 に示すとおりです。

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

リスト 19-12:関連型Itemを持つIteratorトレイトの定義

Itemはプレースホルダであり、nextメソッドの定義は、それがOption<Self::Item>型の値を返すことを示しています。Iteratorトレイトの実装者は、Itemの具体的な型を指定し、nextメソッドはその具体的な型の値を含むOptionを返します。

関連型は、ジェネリクスと似た概念のように見えるかもしれません。後者は、どのような型を処理できるかを指定することなく関数を定義できるためです。この 2 つの概念の違いを調べるために、Item型がu32であることを指定するCounterという名前の型に対するIteratorトレイトの実装を見てみましょう:

ファイル名:src/lib.rs

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        --snip--

この構文はジェネリクスのそれと似ています。では、なぜリスト 19-13 に示すようにジェネリクスを使ってIteratorトレイトを定義しないのでしょうか?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

リスト 19-13:ジェネリクスを使ったIteratorトレイトの仮想的な定義

違いは、リスト 19-13 のようにジェネリクスを使用する場合、各実装で型を注釈する必要があることです。なぜなら、Counterに対してIterator<String>を実装することもできるし、他の任意の型に対しても実装できるため、Counterに対するIteratorの複数の実装が可能になるからです。言い換えると、トレイトにジェネリックパラメータがある場合、それはある型に対して複数回実装でき、そのたびにジェネリック型パラメータの具体的な型を変更できます。Counternextメソッドを使用する場合、使用したいIteratorの実装を示すために型注釈を提供する必要があります。

関連型を使用すると、型を注釈する必要がなくなります。なぜなら、ある型に対してトレイトを複数回実装することはできないからです。関連型を使用する定義のリスト 19-12 では、Itemの型をどのようにするかを一度だけ選ぶことができます。なぜなら、Counterに対するimpl Iteratorは 1 つだけであるからです。Counternextを呼び出す場所すべてで、u32値の反復子を必要とすることを指定する必要はありません。

関連型はまた、トレイトの契約の一部にもなります。トレイトの実装者は、関連型のプレースホルダに代わる型を提供する必要があります。関連型は、その型がどのように使用されるかを表す名前を持つことが多く、API ドキュメントにおいて関連型を文書化するのは良い慣例です。

デフォルトのジェネリック型パラメータと演算子のオーバーロード

ジェネリック型パラメータを使用する場合、ジェネリック型に対してデフォルトの具体的な型を指定することができます。これにより、デフォルトの型が機能する場合、トレイトの実装者が具体的な型を指定する必要がなくなります。<プレースホルダ型=具体的な型>の構文を使ってジェネリック型を宣言する際に、デフォルトの型を指定します。

この技術が役立つ状況の良い例は、演算子のオーバーロードです。これは、特定の状況下で演算子(たとえば+)の動作をカスタマイズするものです。

Rust では、独自の演算子を作成したり、任意の演算子をオーバーロードしたりすることはできません。ただし、std::opsに列挙されている演算と対応するトレイトを、その演算子に関連付けられたトレイトを実装することでオーバーロードすることができます。たとえば、リスト 19-14 では、2 つのPointインスタンスを加算するために+演算子をオーバーロードしています。これは、Point構造体に対してAddトレイトを実装することで行っています。

ファイル名:src/main.rs

use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}

リスト 19-14:Pointインスタンス用に+演算子をオーバーロードするためにAddトレイトを実装する

addメソッドは、2 つのPointインスタンスのx値と 2 つのPointインスタンスのy値を加算して、新しいPointを作成します。Addトレイトには、addメソッドから返される型を決定するOutputという名前の関連型があります。

このコードのデフォルトのジェネリック型は、Addトレイトの中にあります。以下はその定義です:

trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}

このコードは一般的におなじみのものに見えるはずです:1 つのメソッドと関連型を持つトレイトです。新しい部分はRhs=Selfです。この構文はデフォルト型パラメータと呼ばれます。Rhsジェネリック型パラメータ(「右手側」の略)は、addメソッドのrhsパラメータの型を定義します。Addトレイトを実装する際にRhsに具体的な型を指定しない場合、Rhsの型はデフォルトでSelfになります。これは、Addを実装している型になります。

Pointに対してAddを実装する際、2 つのPointインスタンスを加えたかったので、Rhsのデフォルトを使用しました。Rhs型をカスタマイズしたい場合、デフォルトを使用しないAddトレイトの実装の例を見てみましょう。

異なる単位の値を保持する 2 つの構造体MillimetersMetersがあります。既存の型を別の構造体で薄くラップすることは、ニュータイプパターンと呼ばれ、「外部の型に対して外部のトレイトを実装するためのニュータイプパターンの使用」で詳しく説明しています。ミリメートルの値とメートルの値を加え、Addの実装が正しく変換するようにしたいと思います。RhsとしてMetersを使ってMillimetersに対してAddを実装することができます。リスト 19-15 を参照してください。

ファイル名:src/lib.rs

use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}

リスト 19-15:MillimetersMetersを加算するためにMillimetersに対してAddトレイトを実装する

MillimetersMetersを加えるには、Selfのデフォルトを使用する代わりに、Rhs型パラメータの値を設定するためにimpl Add<Meters>を指定します。

デフォルト型パラメータは主に 2 つの方法で使用します。

  1. 既存のコードを破壊することなく型を拡張するため
  2. ほとんどのユーザーが必要としない特定のケースでのカスタマイズを可能にするため

標準ライブラリのAddトレイトは、2 番目の目的の例です。通常、同じ型の 2 つを加えますが、Addトレイトはそれ以上のカスタマイズ機能を提供します。Addトレイトの定義でデフォルト型パラメータを使用することで、ほとんどの場合に余分なパラメータを指定する必要がなくなります。言い換えると、実装のボイラープレートが不要になり、トレイトの使用が容易になります。

最初の目的は、2 番目と似ていますが逆です。既存のトレイトに型パラメータを追加したい場合、既存の実装コードを破壊することなくトレイトの機能を拡張できるように、デフォルトを与えることができます。

同名のメソッドの曖昧さの解消

Rust では、あるトレイトが別のトレイトのメソッドと同じ名前のメソッドを持つことを妨げるものはありませんし、Rust はまた、ある型に対して両方のトレイトを実装することも妨げません。また、トレイトのメソッドと同じ名前のメソッドを型に直接実装することも可能です。

同名のメソッドを呼び出す際、どのメソッドを使用したいかを Rust に伝える必要があります。リスト 19-16 のコードを考えてみましょう。ここでは、PilotWizardの 2 つのトレイトを定義しており、両方ともflyという名前のメソッドを持っています。そして、既にflyという名前のメソッドが実装されているHuman型に対して、両方のトレイトを実装しています。各flyメソッドは異なることを行います。

ファイル名:src/main.rs

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

リスト 19-16:flyメソッドを持つ 2 つのトレイトを定義し、Human型に実装し、Humanに直接flyメソッドを実装する

Humanのインスタンスでflyを呼び出すと、コンパイラはデフォルトで型に直接実装されているメソッドを呼び出します。リスト 19-17 を参照してください。

ファイル名:src/main.rs

fn main() {
    let person = Human;
    person.fly();
}

リスト 19-17:Humanのインスタンスでflyを呼び出す

このコードを実行すると、*waving arms furiously*が表示され、Rust がHumanに直接実装されているflyメソッドを呼び出したことがわかります。

PilotトレイトまたはWizardトレイトのflyメソッドを呼び出すには、どのflyメソッドを意味するのかを指定するために、より明示的な構文を使用する必要があります。リスト 19-18 にこの構文を示します。

ファイル名:src/main.rs

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

リスト 19-18:どのトレイトのflyメソッドを呼び出したいかを指定する

メソッド名の前にトレイト名を指定することで、Rust にどのflyの実装を呼び出したいのかを明確にします。また、Human::fly(&person)と書くこともできます。これは、リスト 19-18 で使用したperson.fly()に相当しますが、曖昧さを解消する必要がない場合は、こちらの方が書くのが少し長くなります。

このコードを実行すると、以下が表示されます。

This is your captain speaking.
Up!
*waving arms furiously*

flyメソッドはselfパラメータを取るため、2 つのが両方とも 1 つのトレイトを実装している場合、Rust はselfの型に基づいてどのトレイトの実装を使用するかを判断することができます。

ただし、メソッドではない関連関数にはselfパラメータがありません。同じ関数名を持つ非メソッド関数を定義する複数の型またはトレイトがある場合、Rust は完全修飾構文を使用しない限り、どの型を意味するのかを常に把握できません。たとえば、リスト 19-19 では、すべての子犬に Spot という名前を付ける動物愛護施設用のトレイトを作成しています。Animalという名前のトレイトを作成し、関連する非メソッド関数baby_nameを定義しています。Animalトレイトは、baby_nameという関連非メソッド関数も直接提供するDog構造体に対して実装されています。

ファイル名:src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}

リスト 19-19:関連関数を持つトレイトと、同じ名前の関連関数を持ち、トレイトも実装する型

子犬すべてに Spot という名前を付けるコードは、Dogに定義されているbaby_name関連関数で実装されています。Dog型はまた、すべての動物が持つ特性を表すAnimalトレイトを実装しています。子犬は子犬と呼ばれ、これはAnimalトレイトのbaby_name関数におけるDogに対するAnimalトレイトの実装で表現されています。

mainでは、Dog::baby_name関数を呼び出しており、これはDogに定義されている関連関数を直接呼び出します。このコードは以下を表示します。

A baby dog is called a Spot

この出力は私たちが望んだものではありません。私たちは、Dogに対して実装したAnimalトレイトの一部であるbaby_name関数を呼び出したいので、コードはA baby dog is called a puppyを表示するようにしたいと思います。リスト 19-18 で使用したトレイト名を指定する手法はここでは役に立ちません。mainをリスト 19-20 のコードに変更すると、コンパイルエラーが発生します。

ファイル名:src/main.rs

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}

リスト 19-20:Animalトレイトのbaby_name関数を呼び出そうとしますが、Rust はどの実装を使用するかを判断できません

Animal::baby_nameにはselfパラメータがないため、Animalトレイトを実装する他の型がある可能性があり、Rust はAnimal::baby_nameのどの実装を私たちが望んでいるのかを判断することができません。このコンパイラエラーが表示されます。

error[E0283]: type annotations needed
  --> src/main.rs:20:43
   |
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^ cannot infer
type
   |
   = note: cannot satisfy `_: Animal`

曖昧さを解消し、Rust に、他の型に対するAnimalの実装とは対照的に、Dogに対するAnimalの実装を使用したいことを伝えるには、完全修飾構文を使用する必要があります。リスト 19-21 に、完全修飾構文の使い方を示します。

ファイル名:src/main.rs

fn main() {
    println!(
        "A baby dog is called a {}",
        <Dog as Animal>::baby_name()
    );
}

リスト 19-21:Dogに対して実装されたAnimalトレイトのbaby_name関数を呼び出したいことを指定するための完全修飾構文の使用

角括弧内に型注釈を提供することで、Rust に対して、この関数呼び出しにおいてDog型をAnimalとして扱うことで、Dogに対して実装されたAnimalトレイトのbaby_nameメソッドを呼び出したいことを伝えています。このコードは、私たちが望む出力を表示します。

A baby dog is called a puppy

一般的に、完全修飾構文は以下のように定義されます。

<Type as Trait>::function(receiver_if_method, next_arg,...);

メソッドではない関連関数の場合、receiverはありません。他の引数のリストのみがあります。関数またはメソッドを呼び出すすべての場所で完全修飾構文を使用することができます。ただし、Rust がプログラムの他の情報から把握できる構文の任意の部分を省略することが許されています。同じ名前を使用する複数の実装があり、Rust がどの実装を呼び出したいのかを特定するのに助けが必要な場合にのみ、このより冗長な構文を使用する必要があります。

スーパートレイトの使用

時には、あるトレイトの定義が別のトレイトに依存する場合があります。ある型が最初のトレイトを実装するためには、その型が 2 番目のトレイトも実装することが必要です。これを行うことで、トレイトの定義で 2 番目のトレイトの関連項目を利用できるようになります。トレイトの定義が依存しているトレイトは、そのトレイトのスーパートレイトと呼ばれます。

たとえば、outline_printメソッドを持つOutlinePrintトレイトを作成したいとしましょう。このメソッドは、与えられた値をアスタリスクで囲んだ形式で表示します。つまり、標準ライブラリのDisplayトレイトを実装して(x, y)となるPoint構造体がある場合、x1y3Pointインスタンスでoutline_printを呼び出すと、以下のように表示されるはずです。

**********
*        *
* (1, 3) *
*        *
**********

outline_printメソッドの実装では、Displayトレイトの機能を使用したいと思います。したがって、OutlinePrintトレイトはDisplayを実装している型のみに対して機能し、OutlinePrintが必要とする機能を提供するように指定する必要があります。これは、トレイト定義でOutlinePrint: Displayと指定することで行うことができます。この技術は、トレイトにトレイト境界を追加するのと似ています。リスト 19-22 にOutlinePrintトレイトの実装を示します。

ファイル名:src/main.rs

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {} *", output);
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

リスト 19-22:Displayからの機能を必要とするOutlinePrintトレイトの実装

OutlinePrintDisplayトレイトを必要とするように指定したので、Displayを実装している任意の型に対して自動的に実装されるto_string関数を使用することができます。トレイト名の後にコロンを付けてDisplayトレイトを指定せずにto_stringを使用しようとすると、現在のスコープ内の型&Selfに対してto_stringという名前のメソッドが見つからないというエラーが表示されます。

Displayを実装していない型(たとえばPoint構造体)に対してOutlinePrintを実装しようとするとどうなるか見てみましょう。

ファイル名:src/main.rs

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

Displayが必要であるが実装されていないというエラーが表示されます。

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:6
   |
20 | impl OutlinePrint for Point {}
   |      ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for
pretty-print) instead
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

これを修正するには、PointDisplayを実装して、OutlinePrintが必要とする制約を満たします。次のようになります。

ファイル名:src/main.rs

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

その後、Pointに対してOutlinePrintトレイトを実装するとコンパイルが成功し、Pointインスタンスでoutline_printを呼び出して、アスタリスクの枠内に表示することができます。

外部のトレイトを実装するためのニュータイプパターンの使用

「型に対するトレイトの実装」の項で、トレイトまたは型のどちらか一方、または両方が私たちのクレートにローカルである場合にのみ、型に対してトレイトを実装することが許されるという孤立則に言及しました。この制限を回避する方法として、ニュータイプパターンを使用することができます。これは、タプル構造体で新しい型を作成することを含みます。(「名前付きフィールドなしのタプル構造体を使用して異なる型を作成する」の項でタプル構造体について説明しました。)タプル構造体は 1 つのフィールドを持ち、トレイトを実装したい型を薄くラップします。そして、ラッパー型は私たちのクレートにローカルであり、ラッパーに対してトレイトを実装することができます。「ニュータイプ」は、Haskell プログラミング言語に由来する用語です。このパターンを使用することによる実行時のパフォーマンスペナルティはありませんし、ラッパー型はコンパイル時に省略されます。

例として、DisplayVec<T>に実装したいとしましょう。孤立則により、直接実装することができません。なぜなら、DisplayトレイトとVec<T>型は私たちのクレートの外で定義されているからです。Vec<T>のインスタンスを保持するWrapper構造体を作成することができます。そして、Wrapperに対してDisplayを実装して、Vec<T>値を使用することができます。リスト 19-23 を参照してください。

ファイル名:src/main.rs

use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![
        String::from("hello"),
        String::from("world"),
    ]);
    println!("w = {w}");
}

リスト 19-23:Displayを実装するためのVec<String>の周りにWrapper型を作成する

Displayの実装では、Wrapperはタプル構造体であり、Vec<T>はタプルのインデックス 0 の項目であるため、self.0を使用して内部のVec<T>にアクセスします。そして、Wrapperに対してDisplay型の機能を使用することができます。

この技術の欠点は、Wrapperは新しい型であるため、保持している値のメソッドを持っていないことです。Wrapperに直接Vec<T>のすべてのメソッドを実装して、メソッドがself.0に委譲するようにする必要があります。これにより、WrapperをまるでVec<T>のように扱うことができます。新しい型が内部型が持つすべてのメソッドを持つことを望む場合、内部型を返すためにWrapperDerefトレイトを実装するのが解決策になります(「Deref を使ってスマートポインタを通常の参照のように扱う」の項でDerefトレイトの実装について説明しました)。Wrapper型が内部型のすべてのメソッドを持つことを望まない場合、たとえばWrapper型の動作を制限する場合、必要なメソッドのみを手動で実装する必要があります。

このニュータイプパターンは、トレイトが関係しない場合でも役立ちます。焦点を切り替えて、Rust の型システムと対話するいくつかの高度な方法を見てみましょう。

まとめ

おめでとうございます!Advanced Traits の実験を完了しました。LabEx でさらに実験を行って、技術力を向上させることができます。