Rustでの 抽象化 3パターンについて
※この記事は全然入門記事ではないです。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)
}
}
EchoCommand
やDoubleEchoCommand
の違いを意識せず、統一的なインターフェイスで実行できるようにします。
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_execute
はEchoCommand
用、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
だけ別の構造体に定義しそちらに固有のメソッドを定義し、Command
のas_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