💬

Rustでの 抽象化 3パターンについて

2022/03/12に公開約6,900字

※この記事は全然入門記事ではないです。Javaなどのオブジェクト指向言語とRustをある程度理解している前提での記事です。あと、メモ程度に雑に書いています。

今回は抽象化がテーマです。オブジェクト指向の多態相当のことをRustでどうのように解決すればいいのでしょうか。個々の実装型の都合によらず、呼び出し側は統一的なインターフェイスで操作するケースはRustでもあるはずです。

オブジェクト指向言語の設計に慣れていると、Rustで設計するときにどうしたらいいかわからないことがあります。なぜならRustには継承がないからです…。まぁJavaと比べるとだいぶ違うので頭を切り替える必要があります。今回はそういう感じの記事です。

では、早速デザインパターンを題材にして考えてみましょう。

抽象化について

簡単なコマンドパターンから考えます。

ここでは単純に渡した文字列を標準出力するコマンドを考えます。以下のような実装です。

pub struct EchoCommand {
  msg: String,
}

impl EchoCommand {
  pub fn new(msg: &str) -> Self {
    Self { msg: msg.to_owned() }
  }

  pub fn run(&self) {
    println!("{}", self.msg)
  }
}

使うときはインスタンスを作ってrunメソッドを呼び出します。

let echo = EchoCommand::new("Hello, world!");
echo.run();

これだけではコマンドパターンになりません。コマンドパターンでは様々な種類のコマンドを定義して、それらの実装の細かい違いを意識せずに、実行できるようにします。

たとえば、以下のようにEchoCommand以外にDoubleEchoCommandがあるとします。

pub struct DoubleEchoCommand {
  msg: String,
}

impl DoubleEchoCommand {
  pub fn new(msg: &str) -> Self {
    Self { msg: msg.to_owned() }
  }

  pub fn run(&self) {
    println!("{}{}", self.msg, self.msg)
  }
}

EchoCommandDoubleEchoCommandの違いを意識せず、統一的なインターフェイスで実行できるようにします。

fn cmd_run(cmd: &コマンド型) {
  cmd.run();
}

let echo = EchoCommand::new("Hello");
cmd_run(&echo);

let double_echo = DoubleEchoCommand::new("Hello");
cmd_run(&double_echo);

cmd_runメソッドの引数型は具体的には書いていませんが、Javaのインターフェイスのように見えます。つまりどうにかして抽象化しなければなりません。Rustでは以下の選択肢があります。

  • トレイト境界を指定した型パラメータを指定する
  • トレイトオブジェクトを指定する
  • enumの型を指定する

ではひとつずつみていきます。

型パラメータ+トレイト境界を使う方法

まず最初にトレイトを作ります。Rustの抽象化機構の一つでまさにインターフェイスのようなものです。

pub trait Command {
  fn execute(&self);
}

以下のようにしてCommandを実装します。

impl Command for EchoCommand {
  fn execute(&self) {
    self.run();
  }
}

DoubleCommandの実装もほぼ同じです。

impl Command for DoubleEchoCommand {
  fn execute(&self) {
    self.run();
  }
}

ジェネリック+トレイト境界の場合は以下のようになります。これはうまくいきます。

fn cmd_execute<T: Command>(cmd: &T) {
  cmd.execute();
}

let echo = EchoCommand::new("Hello");
cmd_execute(&echo);

let double_echo = DoubleEchoCommand::new("Hello");
cmd_execute(&double_echo);

抽象化という意味ではうまくいったのですが、T型はコンパイル時に一つの型に決定されれます。ジェネリック+トレイト境界の場合はスタティックディスパッチになるので、cmd_executeEchoCommand用、DoubleEchoCommand用の実装が二つ用意されていることになります。

もう少し複雑なことをします。MacroCommandは内部に複数のコマンドを保持しまとめて実行できるコマンドです。先ほどと同じ考え方で型パラメータを使って実装します。

pub struct MacroCommand<T: Command> {
  commands: VecDeque<T>,
}

impl<T: Command> MacroCommand<T> {

  pub fn new() -> Self {
    Self {
      commands: VecDeque::new(),
    }
  }

  pub fn append(&mut self, cmd: T) {
    self.commands.push_back(cmd);
  }

  pub fn undo(&mut self) {
    if !self.commands.is_empty() {
      self.commands.pop_front();
    }
  }

  pub fn clear(&mut self) {
    self.commands.clear();
  }
}

impl<T: Command> Command for MacroCommand<T> {
  fn execute(&self) {
    for cmd in &self.commands {
      cmd.execute();
    }
  }
}

MacroCommandを使ってみますが、MacroCommand<EchoCommand>DoubleEchoCommandしようとしてコンパイルエラーになります。これは無理です。EchoCommandを追加した時点でMacroCommand<EchoCommand>の型に固定されるので他の型のコマンドは受け付けることができません。

let mut mc = MacroCommand::new();

mc.append(EchoCommand::new("Hello")); // commandsがVecDeque<EchoCommand>に決定される
mc.append(DoubleEchoCommand::new("Hello")); // コンパイルエラー

cmd_run(&mc);

トレイトオブジェクトを使う方法

では、どう実装したらよいかというと二つ方法があって、一つはトレイトオブジェクトを使う方法です。MacroCommandから型パラメータはなくなり、VecDequeの要素がBox<dyn Command>になります(インスタンスを共有したい場合はBoxではなくRc/Arcを使うとよいでしょう)。

pub struct MacroCommand {
  commands: VecDeque<Box<dyn Command>>,
}

impl Command for MacroCommand {
  fn execute(&self) {
    for cmd in &self.commands {
      cmd.execute();
    }
  }
}

impl MacroCommand {
  pub fn new() -> Self {
    Self {
      commands: VecDeque::new(),
    }
  }

  pub fn append(&mut self, cmd: Box<dyn Command>) {
    self.commands.push_back(cmd);
  }

  pub fn undo(&mut self) {
    if !self.commands.is_empty() {
      self.commands.pop_front();
    }
  }

  pub fn clear(&mut self) {
    self.commands.clear();
  }
}

これはコンパイルできますし、意図どおりに実行できます。

let mut mc = MacroCommand::new();
mc.append(Box::new(EchoCommand::new("Hello")));
mc.append(Box::new(DoubleEchoCommand::new("Hello"))); 
cmd_execute(&mc);

しかし、みて分かるようにBox<dyn Command>になるとダウンキャストしない限りCommandのメソッドしかコールできなくなります(ダウンキャストにはdowncast-rsが便利です)。この方法はダイナミックディスパッチです。つまり実際のサイズはコンパイル時に確定しないので、Box, Rc/Arcなどのヒープに格納することになります。vtable(virtual method table)の解決を行うので実行時のコストも掛かります。

あと、新しいコマンドを追加する際ですが、利用側のロジックはCommandを経由して間接的に依存しているので、実装変更の影響を受けることなく、機能を提供できます。

enumを使う方法

もう一つの選択肢はenumを使う方法です。

enumの方法ではtraitは使いません。trait名をそのままenumとして定義します。列挙値としてEcho, Double, Macroを定義します。executeメソッドではパターンマッチしてそれぞれの処理を実現します。

pub enum Command {
  Echo(String),
  Double(String),
  Macro(MacroCommand),
}

impl Command {
  pub fn of_echo(s: &str) -> Self {
    Command::Echo(s.to_owned())
  }

  pub fn of_macro(commands: VecDeque<Command>) -> Self {
    Command::Macro(MacroCommand { commands })
  }

  pub fn of_macro_with_empty_commands() -> Self {
    Command::Macro(MacroCommand {
      commands: VecDeque::new(),
    })
  }

  pub fn execute(&self) {
    match self {
      Command::Echo(s) => println!("{}", s),
      Command::DoubleEcho(s) => println!("{}{}", s, s),
      Command::Macro(MacroCommand { commands }) => {
        for cmd in commands {
          cmd.execute();
        }
      }
    }
  }
  
  pub fn as_macro(&self) -> Option<&MacroCommand> {
    match self {
      Command::Macro(m) => Some(m),
      _ => None,
    }
  }

  pub fn as_macro_mut(&mut self) -> Option<&mut MacroCommand> {
    match self {
      Command::Macro(m) => Some(m),
      _ => None,
    }
  }

}

pub struct MacroCommand {
  commands: VecDeque<Command>,
}

impl MacroCommand {
  pub fn append(&mut self, cmd: Command) {
    self.commands.push_back(cmd);
  }

  pub fn undo(&mut self) {
    if !self.commands.is_empty() {
      self.commands.pop_front();
    }
  }

  pub fn clear(&mut self) {
    self.commands.clear();
  }
}

Macroだけ別の構造体に定義しそちらに固有のメソッドを定義し、Commandas_macro_mutなどのメソッドを経由して取り出せるようにします。実際の利用例は以下のようになります。

fn cmd_execute(cmd: &Command) {
  cmd.execute();
}

let mut mc = Command::of_macro_with_empty_commands();

let mut macro_cmd = mc.as_macro_mut().unwrap()

macro_cmd.append(Command::of_echo("Hello"));
macro_cmd.append(Command::of_double_echo("Hello"));

cmd_execute(&mc);

だいぶシンプルになりました。enumではスタティックディスパッチになります。が、サブ型毎に実装を分けて書きたいところですが、型が一つになるので同じメソッドに書くことになります。さらに特定のサブ型だけ固有のメソッドを持つ場合は、Macroのように別個の構造体を定義する必要があります。

あと、新しいコマンドを追加する際ですが、利用側のロジックはCommandに直接依存しているので、実装変更の影響を受ける可能性があります。まぁこのあたりが問題になるのであれば、間にtraitを挟むなどやりようはあるとは思います。

Rust way的には多態的なことを考えるときはenumが基本になるのではないかと思いました。

まとめ

ということで、以下の3点に注意しながら抽象化を考えるとよいというお話でした。

  • 型パラメータ+トレイト境界を使う方法
  • トレイトオブジェクトを使う方法
  • enumを使う方法

Discussion

ログインするとコメントできます