🦀

1つの構造体にしか実装予定がなくてもトレイトを定義するメリット

2024/05/07に公開1

トレイトを実装すれば振る舞いを共通化することができ便利ですが、そこだけがメリットなら将来的に一つの構造体でしか実装しない場合は不要な気もします。

個人的にそう思っていたので実装しないケースが多かったのですが、色々コーディングをしていく中でメリットのあるシーンを見つけたのでまとめてみます。

カプセル化

例えば、UserData構造体にgetterメソッドを実装するとします。

UserData構造体の中には機密情報が存在しており、コードレベルでアクセスの制限をかけていきたい場合、トレイトが有効に働きます。

struct UserData {
    username: String,
    password: String, // 機密情報
    email: String,
}

// UserDataの安全な操作を提供するトレイト
trait UserInfo {
    fn username(&self) -> &str;
    fn email(&self) -> &str;
    // パスワードにはアクセスを許さない
}

// UserInfo トレイトの実装
impl UserInfo for UserData {
    fn username(&self) -> &str {
        &self.username
    }

    fn email(&self) -> &str {
        &self.email
    }
}

impl UserData {
    fn password(&self) -> &str {
        &self.password
    }
}

fn print_user_info(user: &impl UserInfo) {
    println!("Username: {}", user.username());
    println!("Email: {}", user.email());
    // 以下のコードは実行できない
    // println!("Password: {}", user.password());
}

fn main() {
    let user = UserData {
        username: "example_user".to_string(),
        password: "secret_password".to_string(),
        email: "user@example.com".to_string(),
    };

    print_user_info(&user);
}

print_user_infoはUserInfoを引数に取ることで、UserInfoトレイトで定義されたメソッドにのみアクセスすることが可能になっているため、passwordのgetterには触れることができません。

インターフェースの分離

トレイトを用いて一つの構造体に機能を実装していけば、カプセル化の項で書いた通りその機能は他の機能と分離されます。

struct Server {
    ip_address: String,
}

// Loggable トレイトは、ログ情報を生成するためのメソッドを定義します。
trait Loggable {
    fn create_log_entry(&self) -> String;
}

// Server 構造体に Loggable トレイトを実装します。
impl Loggable for Server {
    fn create_log_entry(&self) -> String {
        format!("Server accessed at IP: {}", self.ip_address)
    }
}

impl Server {
    fn ip_address(&self) -> &str {
        &self.ip_address
    }
}

fn log_server_activity(server: &impl Loggable) {
    let log_entry = server.create_log_entry();
    println!("{}", log_entry);  // 実際のログ処理ではファイルに書き出すなどの処理が入る
}

fn main() {
    let server = Server { ip_address: "192.168.1.1".to_string() };
    log_server_activity(&server);
}

上記のようなServer構造体に対してLoggableトレイトを実装した場合に、Loggableの処理変更を行った場合、Server構造体の他の機能への影響が発生しないため、メンテナンスや改修などのシーンで膨大な影響範囲に悩まされにくくなります。

テストの容易さ

これはデザインパターンで出てくるリポジトリパターンと同じ話かと思いますが、トレイトをベースに実装することで内部実装への関心を分離させることができるため、型を別途モックとして用意することが簡単になりテストがしやすくなります。

// ユーザー情報を表す構造体
struct User {
    id: u32,
    name: String,
}

// ユーザーリポジトリのトレイト
trait UserRepository {
    fn get_user_by_id(&self, user_id: u32) -> Option<User>;
}

// 実際のデータベースを使用するユーザーリポジトリの実装
struct DbUserRepository;

impl UserRepository for DbUserRepository {
    fn get_user_by_id(&self, user_id: u32) -> Option<User> {
        // 実際のデータベースクエリを想定
        Some(User {
            id: user_id,
            name: "Real User".to_string(),
        })
    }
}

// テスト用のモックリポジトリ
struct MockUserRepository;

impl UserRepository for MockUserRepository {
    fn get_user_by_id(&self, user_id: u32) -> Option<User> {
        // テスト用の固定データを返す
        Some(User {
            id: user_id,
            name: "Test User".to_string(),
        })
    }
}

上記のような実装をすることで、UserRepositoryを引数に持つメソッドをテストの場合はモック構造体を渡すことでテストすることができるようになります。

// ユーザーの名前を取得する関数
fn get_user_name(repo: &impl UserRepository, user_id: u32) -> Option<String> {
    repo.get_user_by_id(user_id).map(|user| user.name)
}

// ユニットテスト
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_get_user_name() {
        let mock_repo = MockUserRepository;
        let user_name = get_user_name(&mock_repo, 1);
        assert_eq!(user_name, Some("Test User".to_string()));
    }
}

まとめ

自分の中でトレイトを実装するメリットは機能の共通化の部分が大きいという認識だったのですが、コードを書いていくうちに「あれ、もっと便利なのでは?」となりました。

個人的にカプセル化をより進めていけるところ魅力的ですね。

これからはトレイトをうまく使って実装していきたいなぁと思うところです。

Discussion

msakutamsakuta

一つ目の例はトレイトでも良いですが、同じ目的にはモジュールを使うことが多いかと思います。

mod user_data {
    pub(super) struct UserData {
        username: String,
        password: String, // 機密情報
        email: String,
    }
    
    impl UserData {
        pub(super) fn new(username: String, password: String, email: String) -> Self {
            Self { username, password, email }
        }

        pub(super) fn username(&self) -> &str {
            &self.username
        }
    
        pub(super) fn email(&self) -> &str {
            &self.email
        }

        fn password(&self) -> &str {
            &self.password
        }
    }
}

use user_data::UserData;

fn print_user_info(user: &UserData) {
    println!("Username: {}", user.username());
    println!("Email: {}", user.email());
    // 以下のコードは実行できない
    // println!("Password: {}", user.password());
}

fn main() {
    let user = UserData::new(
        "example_user".to_string(),
        "secret_password".to_string(),
        "user@example.com".to_string(),
    );

    print_user_info(&user);
}

また、もう一つトレイトの単一実装が役に立つケースがあります。
Rust では外部クレートで定義されたデータ型のメソッドは定義できませんが、独自のトレイトであたかも新しいメソッドを外部クレートの型へ追加したかのような効果を得ることができます。
これは Extension trait と呼ばれるパターンです。
次に示すのは some_crate という外部クレートに UserData が定義されていた場合に full_name というメソッドを Extension trait で追加する例です。

// some_crate
mod user_data {
    pub(super) struct UserData {
        username: String,
        password: String, // 機密情報
        email: String,
    }
    
    impl UserData {
        pub fn new(username: String, password: String, email: String) -> Self {
            Self { username, password, email }
        }
        
        pub fn username(&self) -> &str { &self.username }
        pub fn email(&self) -> &str { &self.email }
    }
}

use some_crate::user_data::UserData;

trait UserDataExt {
    fn full_name(&self) -> String;
}

impl UserDataExt for UserData {
    fn full_name(&self) -> String {
        format!("{} <{}>", self.username(), self.email())
    }
}

fn main() {
    let user = UserData::new(
        "example_user".to_string(),
        "secret_password".to_string(),
        "user@example.com".to_string(),
    );

    println!("fullname: {}", user.full_name());
}

Extension trait には末尾に Ext とつける命名規則があります。