LoginSignup
17
13

More than 1 year has passed since last update.

Rust における継承と合成

Last updated at Posted at 2023-01-10

0. はじめに

Rust を学び始めてから「継承より合成 (Composition over Inheritance 1 2)」という言葉に出会い、OOP において継承が提供する機能を、合成という別の設計方法によって実現できることを知った。私は C++ の比較的古い文法から OOP に入門したので、脳内が継承で凝り固まっていたが、C++ のような言語でも合成の精神でプログラム設計していけることに気づいた 3。Rust では継承が言語機能として実装されていないので、合成の書き方が自然になる。

この記事では、同じ境遇の方を想定して、Rust においてクラス継承に代わる手段の例を説明する。次のような方の参考になるかもしれない。

  • Rust で継承に代わる手段を知りたい。
  • トレイトを使ってインターフェースを実現したが、同じメソッドをコピペして各構造体に実装しなければならないのが嫌だ。
  • 構造体が特定のフィールドを持つことをアテにしたメソッドを作りたいが、コンパイルエラーになる。あるいは、.get_hoge() .as_hoge() 等のメソッドを都度実装しなければならないのが嫌だ。
  • 継承元の構造体をフィールドに持つ継承先の構造体を作った際に、同名のメソッドを再び実装しなければならないのが嫌だ。
  • コードの抽象化には成功しているが、他の設計方法がないか探している。

先に、継承と合成の考え方の比較図を載せておく。上記のような悩みが発生した場合は、このように設計方針を変えると上手くいくかもしれないという話を以下で具体的な例題・コードと共に述べる。

inheritance_vs_composition_game.png

なお、この記事は Rust の基本文法の解説ではなく、文法をどう活用してプログラム設計するかの模索である。文法に関しては、公式によって Rust ツアーというサイトで一通り解説されており、有用である。

また、入門書としては、原旅人『コンセプトから理解する Rust』(技術評論社, 2022) を薦める。この記事では、この本の 5 章も参考にしている。

この記事は、Rust においていかなる場合も継承より合成の方が有用であると主張しているわけではない。性質の違いを認識し、問題に応じて使い分けるのが良いと思う。使用している外部クレートの性質によっても書き方は変わるだろう。そもそも、私はそのような主張ができるほど Rust におけるプログラム経験が無い。この記事は、別解の提案と比較として読んで頂きたい。

節 1 で、インターフェイスを実装するために継承を使いたい状況を、具体的な例題を通じて考える。節 2 では、C++ におけるアップキャストに代わる手段を説明する。節 3 では、機能を追加した構造体を新たに作りたい場合を考える。その後、節 4 で継承と合成の違いについて、抽象的にまとめる。

1. インターフェイスの実装

異なる型のインスタンスに対して同じ機能を持つ (しかし実行内容は異なるかもしれない) 同名のメソッドを実装したい場合に継承が用いられる。もっと言うと、そういったメソッドの実装を各々の型に対して強制したい場合である。例えば、次の問題を考える。

あるお店では、商品 Product を売っていて、それらの商品はフルーツ Fruit と駄菓子 Snack というカテゴリに大別される。各商品は名前 name と値段 price を持つ。フルーツはそれに加え、色 color という特徴を持つ。対して、駄菓子は味 taste という特徴を持つ。

商品を体現するインスタンスに対して、次の 3 つのメソッドを共通の呼び出し方法になるように定義したい。

  • how_much(): その商品の税込み価格を返す。税率は定数 TAX_RATE で表現する。
  • what_this(): その商品の特徴を返す。例えばリンゴの場合、red fruits、チョコの場合、sweet snacks を返したい。
  • print_description(): その商品の特徴と価格を文章にして、標準出力に出力する。

1-1. 継承による設計 (C++)

このとき、継承を積極的に用いた設計は次のようになると思う。C++ のコードで示す。

C++
#include <string>
#include<iostream>
using namespace std;

// 税率を設定
const float TAX_RATE = 1.1;

// 商品を体現する継承用の抽象クラス
class Product {
    string name;
    int price;
public:
    // コンストラクタ
    explicit Product(string name, int price)
    : name(name), price(price) {}
    // 各メソッドの定義 or 継承先クラスでの実装の要求: ここでインターフェイスを実現
    int how_much() const {return (int)(price * TAX_RATE);}
    virtual string what_this() const = 0; // 継承用の純粋仮想関数
    void print_description() const {
	    cout << "The " << name << " is one of the " << what_this() << "." << endl;
	    cout << "The price is " << how_much() << " yen." << endl;
    }
};

// フルーツを体現するクラス: Product を継承
class Fruit: public Product {
    string color;
public:
    // コンストラクタ
    explicit Fruit(string name, int price, string color)
    : Product(name, price), color(color) {}
    // Product の純粋仮想関数をオーバーライド
    string what_this() const override {return color + " fruits";}
};

// 駄菓子を体現するクラス: Product を継承
class Snack: public Product {
    string taste;
public:
    // コンストラクタ
    explicit Snack(string name, int price, string taste)
    : Product(name, price), taste(taste) {}
    // Product の純粋仮想関数をオーバーライド
    string what_this() const override {return taste + " snacks";}
};

int main() {
    Fruit apple("apple", 100, "red");
    apple.print_description();

    Snack chocolate("chocolate", 20, "sweet");
    chocolate.print_description()

    return 0;
}
実行結果
The apple is one of the red fruits.
The price is 110 yen.
The chocolate is one of the sweet snacks.
The price is 22 yen.

継承専用の抽象クラス Product を作り、クラス FruitSnack はそれを継承している。フルーツの商品については Fruit、駄菓子の商品については Snack をインスタンス化することで、それぞれ適切なパラメータを持たせられる。このとき、インターフェイスの実装に関して注目すべきは次の点だと思う。

  • 基底クラス Product にメンバ変数 nameprice を持たせることで、カテゴリ に依らず全ての商品インスタンスがこれらのパラメータを持つことが保証される。
  • 基底クラス Product にメソッド how_much()what_this()print_description() を持たせることによって、全ての商品インスタンスがこれらのメソッドを持つことが保証される。

また、各々のメソッドに関する次の性質も確認しておく。

  • メソッド how_much() の定義は、そのインスタンスがメンバ変数 price を持つことを前提にしている。
  • メソッド what_this() の定義は、継承先の子クラスの持つメンバ変数を前提にしているので、基底クラスでは定義できない。これと上述したインターフェイスの実装を両立するために、基底クラスで純粋仮想関数を用いて宣言している。
  • メソッド print_description() の定義は、そのインスタンスがメソッド how_much()what_this() を持つことを前提にしている。

1-2. 継承のような設計: 抽象クラスをトレイトで代用

Rust においてクラスにいちばん近い型は、メソッドが実装された構造体である。上述の C++ コードと同じ全体設計で Rust を書くならば、次のような発想になるかもしれない。構造体には言語機能としての継承はないので、代わりにトレイトを用いて表現してみる。

Rust のトレイトは、構造体の持つメソッドを規定することができるが、抽象クラスとは違い、構造体の持つフィールドを規定することはできない4。このため、フィールドの抽象化は諦めて、Product トレイトによってメソッドに関するインターフェースを実装する方法が考えられる。

この方法をとる場合、次のように、トレイトのデフォルト実装で、フィールドにアクセスできないことが障壁になる。

Rust
struct Fruit {
    name: String,
    price: u32,
    color: String,
}

trait Product {
    fn how_much(&self) -> u32 {
        self.price        
    }
}

impl Product for Fruit {}
エラー内容
error[E0609]: no field `price` on type `&Self`
 --> src/main.rs:9:14
  |
7 | trait Product {
  | ------------- type parameter 'Self' declared here
8 |     fn how_much(&self) -> u32 {
9 |         self.price        
  |              ^^^^^

For more information about this error, try `rustc --explain E0609`.

トレイトのデフォルト実装の時点では、実装先の構造体が self.price を持つことが保証されないので、コンパイラに怒られる。このため、次のように各構造体に対してメソッドを都度実装していくのがひとつの手である。

Rust
const TAX_RATE: f32 = 1.1;

// メインプログラムでインスタンス化する構造体たちを定義
struct Fruit {
    name: String,
    price: u32,
    color: String,
}
// コンストラクタ
impl Fruit {
    fn new(name: &str, price: u32, color: &str) -> Self {
        Self {
            name: name.to_string(),
            price: price,
            color: color.to_string(),
        }
    }
}

struct Snack {
    name: String,
    price: u32,
    taste: String,
}
// コンストラクタ
impl Snack {
    fn new(name: &str, price: u32, taste: &str) -> Self {
        Self {
            name: name.to_string(),
            price: price,
            taste: taste.to_string(),
        }
    }
}

// 商品としてのふるまいを規定するトレイト
trait Product {
    fn how_much(&self) -> u32;
    fn what_this(&self) -> String;
    fn print_description(&self);
}

// 各構造体へ実装
impl Product for Fruit {
    fn how_much(&self) -> u32 {
        (self.price as f32 * TAX_RATE) as u32
    }
    fn what_this(&self) -> String {
        self.color.clone() + " fruits"
    }
    fn print_description(&self) {
        println!("The {} is one of the {}.\nThe price is {} yen.",
            &self.name, self.what_this(), self.how_much());
    }
}
impl Product for Snack {
    fn how_much(&self) -> u32 {
        (self.price as f32 * TAX_RATE) as u32
    }
    fn what_this(&self) -> String {
        self.taste.clone() + " snacks"
    }
    fn print_description(&self) {
        println!("The {} is one of the {}.\nThe price is {} yen.",
            &self.name, self.what_this(), self.how_much());
    }
}

fn main() {
    let apple = Fruit::new("apple", 100, "red");
    apple.print_description();

    let chocolate = Snack::new("chocolate", 20, "sweet");
    chocolate.print_description();
}

この方法のメリットは、各々の構造体に対してメソッドの定義を一箇所にまとめられる点だろう。しかし、今の文脈では how_much()print_description() の定義を何度もコピペしなければならないデメリットの方が大きいので、改良案を次節で述べる。

1-3. 継承のような設計 <改>: ゲッターメソッド

前節の設計では、トレイトによって構造体のフィールドの規定ができないことから、フィールドの存在を前提にしたメソッドをデフォルト実装できなかった。そのため、共通の定義を持つメソッドも個別にコピペして実装しなければならなくなった。

これに対する処方には、ゲッターメソッドを用いるものがある。Product が共通して持つべきフィールドの値を取得するメソッドをトレイトによって要求することで、間接的にそのフィールドの所持を要求する。

Rust
const TAX_RATE: f32 = 1.1;

// メインプログラムでインスタンス化する構造体たちを定義
struct Fruit {
    name: String,
    price: u32,
    color: String,
}
// コンストラクタ
impl Fruit {
    fn new(name: &str, price: u32, color: &str) -> Self {
        Self {
            name: name.to_string(),
            price: price,
            color: color.to_string(),
        }
    }
}

struct Snack {
    name: String,
    price: u32,
    taste: String,
}
// コンストラクタ
impl Snack {
    fn new(name: &str, price: u32, taste: &str) -> Self {
        Self {
            name: name.to_string(),
            price: price,
            taste: taste.to_string(),
        }
    }
}

// 商品としてのふるまいを規定するトレイト
trait Product {
    // 共通のフィールドに対しゲッターメソッドを実装要求
    fn get_name(&self) -> String;
    fn get_price(&self) -> u32;
    // 共通のメソッドはデフォルトで定義
    fn how_much(&self) -> u32 {
        (self.get_price() as f32 * TAX_RATE) as u32
    }
    fn what_this(&self) -> String;
    fn print_description(&self) {
        println!("The {} is one of the {}.\nThe price is {} yen.",
            &self.get_name(), self.what_this(), self.how_much());
    }
}

// 各構造体へ実装
impl Product for Fruit {
    fn get_name(&self) -> String {self.name.clone()}
    fn get_price(&self) -> u32 {self.price}
    fn what_this(&self) -> String {
        self.color.clone() + " fruits"
    }
}
impl Product for Snack {
    fn get_name(&self) -> String {self.name.clone()}
    fn get_price(&self) -> u32 {self.price}
    fn what_this(&self) -> String {
        self.taste.clone() + " snacks"
    }
}

fn main() {
    let apple = Fruit::new("apple", 100, "red");
    apple.print_description();

    let chocolate = Snack::new("chocolate", 20, "sweet");
    chocolate.print_description();
}

ゲッターメソッド自身は各構造体に対して用意しなければならないが、それらを定義してしまえば、Product に共通するメソッドの定義が一度だけで済むようになった。

この方針では、C++ の継承機能をトレイトで代用しているが、プログラムの全体設計は節 1-1 と同じである。同じトレイトを実装することによって、FruitSnack のふるまいを共通化している。

1-4. 合成による設計: 機能ごとに構造体を用意

継承の考え方では、メインプログラムでインスタンス化するのは FruitSnack のような具体カテゴリを表す型だった。それらに共通する性質をインターフェース Product にまとめ、実装することによって、それらの所持を保証した。

対して、合成の考え方では次のように考えるのが自然だと思う。インスタンス化するのは商品全体を表す親構造体 Product である。商品をその機能 (構成要素) ごとに分解し、各機能に対し構造型を用意する。各機能に関するメソッドは、各々の子構造体に実装する。子構造体の抽象化にトレイトを用いる。最後にそれらの子構造体を親構造体に持たせる。子構造体の組み合わせを変えることで、親構造体のバリエーションを作り、共通した子構造体 (あるいは共通したトレイトを持った子構造体) を持たせることで、親構造体のふるまいを共通化する。

具体的には、Product が値札 Tag と内容 Content を持つと考える。name, price を持つのは Tag であり、特徴 (colortaste) を持つのは Content である。メソッド how_much()Tag に対するメソッドなので、Tag 構造体に実装する。他のメソッドについても同様に、その機能を体現する構造体に紐づけていく。

Content に関しては、それぞれの具体カテゴリ Fruit, Snack が持つべきフィールドが異なるので、共通した型を作ることができない。この場合は、Content に共通するふるまいをトレイトによって規定する。そして、ジェネリクスとトレイト境界を駆使し、親構造体にこれらの子構造体を持たせる。

Rust
const TAX_RATE: f32 = 1.1;

// メインプログラムでインスタンス化する構造体
// 商品 (Product) は値札 (Tag) と内容 (Content) を持つ
// Content トレイトを持つ任意の型を content フィールドに持てる
struct Product<T: Content> {
    tag: Tag,
    content: T,
}

// Tag には name と price が書かれている
struct Tag {
    name: String,
    price: u32,
}
impl Tag {
    // コンストラクタ
    fn new(name: &str, price: u32) -> Self {
        Tag {name: name.to_string(), price}
    }
    // Tag に対するメソッドは Tag に紐づける
    fn how_much(&self) -> u32 {
        (self.price as f32 * TAX_RATE) as u32
    }
}

// Content に対して what_this() メソッドを要求
trait Content {
    fn what_this(&self) -> String;
}

// Content の具体バリエーション
struct Fruit {color: String}
impl Fruit {
    // コンストラクタ
    fn new(color: &str) -> Self {
        Fruit {color: color.to_string()}
    }
}
impl Content for Fruit {
    fn what_this(&self) -> String {
        self.color.clone() + " fruits"
    }
}

struct Snack {taste: String}
impl Snack {
    // コンストラクタ
    fn new(taste: &str) -> Self {
        Snack {taste: taste.to_string()}
    }
}
impl Content for Snack {
    fn what_this(&self) -> String {
        self.taste.clone() + " snacks"
    }
}

// print_description() は Product に対して実装
impl<T: Content> Product<T> {
    fn print_description(&self) {
        println!("The {} is one of the {}.\nThe price is {} yen.",
            &self.tag.name, 
            self.content.what_this(), 
            self.tag.how_much());
    }
}

// Product のコンストラクタ
impl Product<Fruit> {
    fn new(name: &str, price: u32, color: &str) -> Self {
        Self {
            tag: Tag::new(name, price),
            content: Fruit::new(color),
        }
    }
}
impl Product<Snack> {
    fn new(name: &str, price: u32, taste: &str) -> Self {
        Self {
            tag: Tag::new(name, price),
            content: Snack::new(taste),
        }
    }
}

fn main() {
    let apple = Product::<Fruit>::new("apple", 200, "red");
    apple.print_description();

    let chocolate = Product::<Snack>::new("chocolate", 20, "sweet");
    chocolate.print_description();
}

メソッド print_description()TagContent 両方のメソッドをアテにしているので、親構造体 Product に紐づけた。この際に、Content トレイトで what_this() メソッドを要求していることと、Product の定義時にトレイト境界 T: Content を引いていることが重要である。これにより、Product のフィールド content の具体型が定まらなくても、それが what_this() を持つことは保証されるので、print_description() の定義に使える。

また、Content 用の子構造体を作ることも大切である。仮に、次のようにして定義された Fruit 構造体をメインプログラムでインスタンス化する方針を取った場合、print_description() の定義を一度にすることが難しくなるだろう。

Rust
struct Fruit {
    tag: Tag,
    color: String,
}

2. ポリモーフィズムの実現

共通のふるまいをする複数の型を、その具体的な型に囚われずに一般的に扱うことをポリモーフィズムという5

2-1. アップキャストとオーバーライド → トレイト境界

(他の言語は知らないが) C++ では、アップキャストを用いてポリモーフィズムを実現するために、継承を用いることもある。Rust で同じ機能を実現するには、ジェネリクスとトレイト境界を用いることになる。

例えば、節 1-1 で述べた C++ のコードに次の関数を追加し、メインプログラムを実行する。

C++
void print_character(const Product& product) {
    cout << product.what_this() << endl;
}

int main() {
    Fruit apple("apple", 100, "red");
    Snack chocolate("chocolate", 20, "sweet");

    print_character(apple);
    print_character(chocolate);

    return 0;
}
実行結果
red fruits
sweet snacks

これは、継承先クラスの参照を基底クラスの参照にキャストできることを用いた設計である。print_character() では基底クラスの参照を仮引数で受け取っているので、基底クラスで宣言されているメソッドは使える。メインプログラムの実引数では、継承先のクラスの参照を渡している。そのため、what_this() メソッドについては、継承先でオーバーライドされたものが呼び出される。このようにして、同じ print_character() 関数で複数の型を引数に取ることができる。

節 1-3 の継承のような設計において同じ関数を実装するには、ジェネリクスとトレイト境界、あるいはそれの簡略記法である impl 構文を用いる。

Rust
// Product トレイトを持つ任意の型の参照を引数に取る
fn print_character(product: &impl Product) {
    println!("{}", product.what_this()); // Product トレイトのメソッドは使える
}

fn main() {
    let apple = Fruit::new("apple", 100, "red");
    let chocolate = Snack::new("chocolate", 20, "sweet");

    print_character(&apple);
    print_character(&chocolate);
}

節 1-4 の合成による設計ならば、メインプログラムでインスタンス化する型は全て同じ Product なので、引数を Product 型に取ればよい。ただし、content 内の what_this() を用いるには、トレイト境界を引く必要がある。

Rust
fn print_character<T: Content>(product: &Product<T>) {
    println!("{}", product.content.what_this());
}

fn main() {
    let apple = Product::<Fruit>::new("apple", 200, "red");
    let chocolate = Product::<Snack>::new("chocolate", 20, "sweet");

    print_character(&apple);
    print_character(&chocolate);
}

あるいは、次のように content を引数に取ることで、関数に渡す情報を最小限にした方が分かりやすくなるかもしれない。

Rust
// Content トレイトを持つ任意の型の参照を引数に取る
fn print_character(content: &impl Content) {
    println!("{}", content.what_this());
}

fn main() {
    let apple = Product::<Fruit>::new("apple", 200, "red");
    let chocolate = Product::<Snack>::new("chocolate", 20, "sweet");

    print_character(&apple.content);
    print_character(&chocolate.content);
}

2-2. 動的ディスパッチ

上の例では、メインプログラムでインスタンス化した時点で、apple の型はフルーツに決まり、chocolate の型は駄菓子に決まる。print_character() はフルーツ型とチョコレート型に対して用いられることがあらかじめ分かっているので、Rust ではコンパイル時にこの 2 つの型に対する print_character() が生成 (複製) される。つまり、実行時には、自分で 2 つの型に対する print_character() を定義したのと同じコードの状態になっている。これがジェネリクスの役割であり、静的ディスパッチなどと呼ばれる。

一方で、商品インスタンスを要素に取るベクタを宣言したい場合を考える。ベクタの各要素は、それぞれフルーツかもしれないし、駄菓子かもしれない。ジェネリクスとトレイト境界では、Vec<Fruit>Vec<Snack> のような、一貫して同じ型を要素に持つベクタしか作れないので、両者が入り混じったベクタを生成できない。こういう問題は動的ディスパッチと呼ばれたりする6

動的ポリモーフィズムを実現するには、enum 型を用いる方法とトレイトオブジェクトを用いる方法がある。詳細は、例えば次の記事を参考にして欲しい。

2-3. Deref を使って継承を真似する (要注意)

std::ops::Deref トレイトを実装することで、まるで参照外しのようにして、「派生」構造体がフィールドとして持つ「基底」構造体のメソッドやフィールド (の参照) にアクセスできるようになる7。これにより、C++ のアップキャストに似た設計を実装できる。しかし、Rust の文法的におかしい挙動をすることになるので、使用には注意を要する。

Rust
use std::ops::Deref;

// 基底クラスの代用
#[derive(Debug)]
struct Base {field: u32}

impl Base {
    fn print_hello(&self) {
        println!("Hello")
    }
}

// 派生クラスの代用
#[derive(Debug)]
struct Derived {
    base: Base,
}

impl Deref for Derived {
    type Target = Base;
    fn deref(&self) -> &Base {
        &self.base
    }
}

fn main() {
    let derived = Derived {base: Base {field: 10}};
    derived.print_hello();
    println!("{}", derived.field);
    println!("{:?}", derived);
    println!("{:?}", *derived);
}
実行結果
Hello
10
Derived { base: Base { field: 10 } }
Base { field: 10 }

3. 機能の追加

継承を使う最も直接的な場面は、ある型が持つフィールドやメソッドを引き継ぎ、かつ新たな機能を持った別の型を定義する場合だと思う。もっと言うと、古い型と新しい型の共通項については同じふるまいをさせたい場合である。節 1 で考えた問題と同じ店における別の問題を考える。なお、以下では簡素化のため、コンストラクタやメインプログラムを省き、重要な部分のみを切り抜いたコードを提示する。

通常の商品 Product に加え、同種の商品を袋詰めした商品 PackedProduct も売ることになった。これに伴い、プログラムに対して新たな要求が発生した。

  • 要求 1: PackedProductProduct の持つ変数に加え、個数 (内容量) number という変数を持つ。
  • 要求 2: PackedProductProduct の持つメソッドに加え、unit_price() というメソッドを持つ。これは、袋詰めされた商品 1 個あたりの値段を返す。
  • 要求 3: print_description() メソッドについては、PackedProduct 用の定義を加えたい。
  • 要求 4: ProductPackedProduct が共通して持つメソッドについては、注目するインスタンスが Packed かどうかを気にすることなく、同じ書き方で呼び出せるようにしたい。

3-1. 継承による設計 (C++)

継承を積極的に用いるならば、Product を継承して機能を追加した PackedProduct クラスを作る発想になると思う。節 1-1 でのコードに次の変更を加える。Product を継承した Fruit クラスとは別に、PackedProduct を継承した PackedFruit クラスを作り、メインプログラムでインスタンス化する。print_description() メソッドについては、Product での定義に virtual 指定子を付け、PackedProduct でオーバーライドすればよい。

C++
// 袋詰め商品を体現する抽象クラス
class PackedProduct: public Product {
protected:
    int number;
public:
    int unit_price() const {return price / number;}
    void print_description() const override {
        cout << "This is a packed product. The number of contents is " << number << "." << endl;
	    cout << "The " << name << " is one of the " << what_this() << "." << endl;
	    cout << "The price is " << how_much() << " yen." << endl;
    }
};
// 袋詰めされたフルーツ
class PackedFruit: public PackedProduct {
    string color;
public:
    string what_this() const override {return color + " fruits";}
};

この方法のメリットは、機能の追加が直接的に実装されていて分かりやすい点である。上述した要求 4 については、言語の機能として自動的に保証される。

しかし、注目しているクラスがどんな変数やメソッドを持つのかが分かりにくい。また、what_this() については、FruitPackedFruit に対し、同じ定義を二度書かなければならなくなっている。この定義を共通化するには多重継承を用いる手があるが、すると、全体の設計は合成によるものに近づくことになる。

3-2. 継承のような設計: トレイトの継承

節 1-3 の方針を採用するならば、トレイトの継承を用いることになる。Product トレイトを継承した新たなトレイト Packed を作る。これにより、Packed を実装した構造体は必ず Product も実装しなければならないので、Product で宣言されたメソッドも用いた定義を行える。PackedFruit 構造体を新たに作り、ProductPacked を実装する。

トレイトの継承では、メソッドのオーバーライドはできない。言い換えると、ある構造体に 2 つのトレイトを実装する際に、それらのトレイトが同じ名前のメソッドを持っているとコンパイルエラーになる。しかしながら、トレイトにデフォルト実装されたメソッドを、構造体への実装 (impl for 構文) 時にオーバーライドすることはできる。この場合、オーバーライドしなかった構造体にのみ、デフォルト実装での定義が適用される。この機能を用いて print_description() の更新を行う。

Rust
trait Packed: Product {
    fn get_number(&self) -> u32;
    fn unit_price(&self) -> u32 {
        self.get_price() / self.get_number()
    }
}
struct PackedFruit {
    name: String,
    price: u32,
    number: u32,
    color: String,
}
impl Product for PackedFruit {
    fn get_name(&self) -> String {self.name.clone()}
    fn get_price(&self) -> u32 {self.price}
    fn what_this(&self) -> String {
        self.color.clone() + " fruits"
    }
    fn print_description(&self) {
        println!("This is a packed product. The number of contents is {}.\nThe {} is one of the {}.\nThe price is {} yen.",
            self.unit_price(),
            self.get_name(),
            self.what_this(),
            self.how_much()
        );
    }
}
impl Packed for PackedFruit {
    fn get_number(&self) -> u32 {self.number}
}

PackedFruitProduct トレイトを実装し忘れるとコンパイルエラーになる。故に、Fruit と同じトレイトを実装しなければならないので、要求 4 は保証される。

Product に関連したフィールドについては、新たな構造体を追加するたびに、全て書き直さないといけない。フィールド数が多くなるとややこしくなるかもしれない。また、フィールドを増やすたびに、関連する全ての構造体に対してゲッターメソッドを追加しなければならない。前節と全体設計は同じなので、what_this() の定義ダブり問題も健在である。

3-3. 委譲による設計: 構造体の入れ子

合成の設計は委譲による依存関係の構築を基礎にしている。構造体 (クラス) A の機能を構造体 B にも持たせたい場合に、B にフィールドとして A を持たせてしまうのが委譲である。この節では、委譲を用いることで、節 3-1 と同じ全体設計の実現を試みる。しかし、この節の方針は今回の問題に対する良い解ではないかもしれない。

Rust
const TAX_RATE: f32 = 1.1;

struct Product {
    name: String,
    price: u32,
}
impl Product {
    fn how_much(&self) -> u32 {
        (self.price as f32 * TAX_RATE) as u32
    }
}

// Product の機能を委譲
struct PackedProduct {
    product: Product,
    number: u32,
}
impl PackedProduct {
    // how_much() を再定義(転送)
    fn how_much(&self) -> u32 {self.how_much()}
    fn unit_price(&self) -> u32 {
        self.product.price / self.number
    }
}

// Product または PackedProduct の機能を
// それぞれの構造体に委譲
struct Fruit {
    product: Product,
    color: String,
}

struct PackedFruit {
    product: PackedProduct,
    color: String,
}

このコードでは、メインプログラムでインスタンス化する構造体の機能の一部を、入れ子のようにして委譲している。今回の問題では、FruitPackedFruit をインスタンス化 (obj) した際に、共通して obj.product.how_much() という形式でメソッドを呼び出したい。このため、Product で定義した how_much()PackedProduct のメソッドとして呼び出すために、再定義が必要になっている。

上では示さなかったが、what_this()print_description() については、FruitPackedFruit が共通して持つトレイトを定義して要求し、各々の構造体へ個別に定義することになるだろう。この定義の共通化をしようとすると、プログラム構造がややこしくなってくる。次の記事では、トレイトの継承と .as_hoge() メソッドを用いた実現方法が説明されている。

3-4. 合成による設計: 機能ごとに構造体を用意

節 1-4 の合成による方針を採用する場合、次のように考える。まず、節 2-1 で考えたような関数において、全商品を抽象的に扱いたいので、インスタンス化する構造体として Product<T> に加えて PackedProduct<T> を新たに作るようなことはしたくない。よって、Product<T> の持つフィールドを変更することによって、機能を追加する。

具体的には Fruit の機能に Packed の機能を加えた PackedFruit 構造体を作り、content フィールドとして持たせることもできるだろう。しかし、content フィールドは既に、商品カテゴリ (Fruit or Snack) の数 N だけバリエーションを持つので、更に PackedUnPacked の軸を増やすと、N × 2 の構造体を用意しなければならない。また、今回追加するメソッド unit_price()Tag の情報を必要とする。これらを踏まえると、tag フィールド用の構造体として、Tag とは別の構造体を用意するのがより良い策に思われる。Tag 構造体に Packed の機能を追加した構造体 TagForPacked を作る際には、節 3-2 のトレイト継承を用いた方法と、節 3-3 の委譲を用いた方法がある。

この節では、合成の考え方に固執し、Product<T> に新たなフィールドを加える方針を取る。いちばん簡単な方法は、袋詰めされていない商品を、1個だけ袋詰めされている商品と同一視し、全ての商品インスタンスが number を持つようにするものである。この一般化が許されるのであれば、この方法が最善だろう。しかし、それではこの節の文脈を逸脱してしまう。

袋詰めされていない商品 (number を持たない商品) と袋詰めされた商品 (number を持つ商品) を区別するためには、Product<T> に持たせる number をオプショナルにしたいが、Rust にはオプショナルフィールドを持たせられない。代わりに、Option<u32> 型を number フィールドに持たせることになる。こうすれば、number を持たないことは、None によって表現される。この方針を取った場合、unit_price() メソッドは全ての Product<T> に実装される。よって、unit_price() の定義内で number フィールドが Some()None かによる場合分けが必要になるだろう。袋詰めされていない商品に対して unit_price() メソッドを呼び出した場合に、実行時ではなくコンパイル時エラーを出したいのならば、そのような Product<T> にはメソッドを実装すべきではない。

そのような場合、ジェネリクスを用いるのが良いと思う。節 1-4 のコードに次の変更を加える。

Rust
// 新たに bag フィールドを追加
// bag フィールドには、Bag (袋) トレイトを持つ任意の型が入りうる
struct Product<T: Content, U: Bag> {
    tag: Tag,
    content: T,
    bag: U,
}

// bag フィールド用に Packed と UnPacked の 2 つの型を用意
// Packed 構造体は number (袋詰めされた個数) を持つ
struct Packed {number: u32}
struct UnPacked;
trait Bag {}
impl Bag for Packed {}
impl Bag for UnPacked {}

// Packed Product にのみ unit_price() を実装
impl<T: Content> Product<T, Packed> {
    fn unit_price(&self) -> u32 {
        self.tag.price / self.bag.number
    }
}

// print_description() の定義は UnPacked の場合と Packed の場合の 2 通り
impl<T: Content> Product<T, UnPacked> {
    fn print_description(&self) {
        println!("The {} is one of the {}.\nThe price is {} yen.",
            &self.tag.name, 
            self.content.what_this(), 
            self.tag.how_much());
    }
}
impl<T: Content> Product<T, Packed> {
    fn print_description(&self) {
        println!("This is a packed product. The number of contents is {}.\nThe {} is one of the {}.\nThe price is {} yen.",
            &self.bag.number,
            &self.tag.name, 
            self.content.what_this(), 
            self.tag.how_much());
    }
}

メインプログラムで、例えば袋詰めされたフルーツのインスタンスを生成したい場合は、Product<Fruit, Packed> をインスタンス化すれば良い。この方針の場合、親構造体が共通の型をフィールドに持つことと、メソッド実装の際のジェネリクスによって、要求 4 が保証される。

この方法のメリットは、定義文のダブりが無く、ボイラープレート8が最小限に抑えられていることである。また、インスタンス化する構造体がどのような機能を持っているのかが比較的見渡しやすいように感じる。

4. 継承と委譲と合成

継承 (inheritance) と対比される言葉には、合成 (composition) の他に、委譲 (delegation) がある。ここで述べる委譲は、本来は転送 (forwarding) と呼ぶべきものであるようだ9。しかし、今では多くの場合誤用されるようなので、この記事でも委譲と書いてしまう。

継承と委譲を比較した際は、具体的な実装方法の違いに注目している。Base 構造体の持った機能に新たな機能を加えた構造体 Derived を作りたいときに、言語機能としての継承とは、Base のフィールドとメソッドを Derived にも強制的に持たせる機能である。この継承を用いる代わりに、DerivedBase のインスタンスを持たせ、メソッドを再定義することで、同じプログラム設計を実現しようとするのが委譲 (転送) である。

OOP 言語における継承と委譲の比較については、例えば次の記事に書かれている。

まとめると、BaseDerived がプログラムにおいて同じ役割を果たす型であるならば継承、両者の役割が異なるならば委譲を用いた方が良いようだ。Rust では、継承が言語機能として存在しないので、委譲の有効範囲がもう少し広がる。これまで述べてきたように、Rust では次の 2 つの方法がある。

  • Rust 式継承: トレイト継承機能を用いる。フィールドを継承したい場合は、ゲッターメソッドを用いて間接的に行う。
  • 委譲: 素直に委譲 (転送) を行う。

Rust 式継承ではゲッターメソッド、委譲ではメソッドの再定義 (転送) がボイラープレート8になる。

委譲において、ボイラープレートを抑えるには、プログラム全体の設計を見直し、構造体間の依存関係を変更する必要がある。具体的には、なるべく役割が異なる型間でのみ委譲を行うようにする。役割が同じ型間での委譲を避けるために、役割をさらに細かく分割する。各々の機能を備えた型を、最後にまとめて 1 つの構造体に委譲することで、インスタンスに要求される全ての機能を備えた型を作る。これが合成の基本方針である。継承と合成を比較した場合、プログラムの設計方針に注目している。

inheritance_vs_composition.png

  1. Rust における継承より合成が説明されている記事: https://medium.com/comsystoreply/28-days-of-rust-part-2-composition-over-inheritance-cab1b106534a

  2. 継承より合成のウィキペディア: https://en.wikipedia.org/wiki/Composition_over_inheritance

  3. 特に、C++20 では、Rust のトレイト境界に相当する「コンセプト」という機能が搭載されたので、以下に述べるような設計は C++ でもできるようになっているのだと思う。私はまだ使ったことがない。: https://cpprefjp.github.io/lang/cpp20/concepts.html

  4. 今は構造体を考えているが、Rust ではその他の型にもメソッドやトレイトを実装できる。

  5. ポリモーフィズムのウィキペディア: https://ja.wikipedia.org/wiki/%E3%83%9D%E3%83%AA%E3%83%A2%E3%83%BC%E3%83%95%E3%82%A3%E3%82%BA%E3%83%A0

  6. C++ のアップキャストとオーバーライドを使った方法は、動的ディスパッチである。一般には、静的に行った方が実行パフォーマンスが良いので、静的な問題には静的に対応したい。C++ ではそのような場合、テンプレート機能が用いられる。

  7. 参考: https://github.com/rust-unofficial/patterns/blob/main/anti_patterns/deref.md

  8. 言語の仕様上仕方なく発生してしまう定型コードのこと: https://ja.wikipedia.org/wiki/%E3%83%9C%E3%82%A4%E3%83%A9%E3%83%BC%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88%E3%82%B3%E3%83%BC%E3%83%89 2

  9. 委譲と転送の違いについて: https://qiita.com/jesus_isao/items/4b6b7846ccf5eb46b1bc

17
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
13