C++ ときどき ごはん、わりとてぃーぶれいく☆

USAGI.NETWORKのなかのひとのブログ。主にC++。

Rust のダウンキャストめんどくさいでござる問題を mopa crate ですっきりするメモ

trait T を実装した struct S について、 S から &dyn T へアップキャストした後、 &dyn T から &S へダウンキャストでき…ません。Rustでtrait継承のdynなオブジェクトを扱おうとすると悲しむポイントです。

trait T { fn hello(&self); }
struct S {}
impl T for S{ fn hello(&self){ println!("It's S!") } }
impl S { fn hi(&self) { print!( "I can do HI!" ) }

↑これが定義できる、というところまではよいとして、↓はできません。

fn main()
{
 // OOPの派生型に相当する型の元のオブジェクト
 let s: S = S{};
 // OOPの基底型に相当するトレイトオブジェクトレベルでの保持
 let rs: &dyn T = &s as &dyn T;
 // トレイトオブジェクトレベルでの関数呼び出しと自動的な派生型の関数へのディスパッチ
 rs.hello();
 // OOPで言うところの基底型から派生型へのダウンキャスト
 let s: &S = rs as &S;
 // ダウンキャスト済みなのでダウンキャスト先の派生型にしかない関数も呼べるはず
 s.hi();
}
error[E0605]: non-primitive cast: `&dyn T` as `&S`
 --> src\main.rs:9:14
  |
9 |  let s: &S = rs as &S;
  |              ^^^^^^^^ an `as` expression can only be used to convert between primitive types or to coerce to a specific trait object

error: aborting due to previous error

「にゃーん?」って言ったら助けてくれるイケメンが臨席にいればよいのですが、いません。これは↓のように std::any::Any を介するとできます:

// にゃーんだけど一応動く…と見せかけて期待した実行結果にならないやつ
trait T: std::any::Any { fn hello(&self); }
struct S { }
impl T for S{ fn hello(&self){ println!("It's S!") } }
impl S { fn hi(&self) { print!( "I can do HI!" ) } }

fn main()
{
 // OOPの派生型に相当する型の元のオブジェクト
 let s: S = S{};
 // OOPの基底型に相当するトレイトオブジェクトレベルでの保持
 let rs: &dyn std::any::Any = &s as &dyn std::any::Any;
 // トレイトオブジェクトレベルでの関数呼び出しと自動的な派生型の関数へのディスパッチ
 match rs.downcast_ref::<&dyn T>()
 {
  // Any から T へのダウンキャスト、呼べる…ハズヨネ??
  Some(t) => t.hello(),
  None => println!("downcast to T was failed.")
 }
 // OOPで言うところの基底型から派生型へのダウンキャスト
 match rs.downcast_ref::<S>()
 {
  // ダウンキャスト済みなのでダウンキャスト先の派生型にしかない関数も呼べるはず
  Some(s) => s.hi(),
  None => println!("downcast to S was failed.")
 }
}
downcast to T was failed.
I can do HI!

Rustなのにいろいろすっとばして突然の動的型付け言語化です。お気持ちとしては Any までアップキャストしなくてもいいのよ… C++dynamic_cast 的な…と思いますが、それを trait で実装しているのが std::any::Any さんなのでにゃーんです。翻訳できて実行できて、だけど Any から T へのダウンキャストに失敗しています。悲しい。

Any から T.downcast_ref できないので、仕込みを増やしてどうにかします:

// Any 化するトレイトを追加
trait A { fn as_any(&self) -> &dyn std::any::Any; }
// T を A 派生にして
trait T: A { fn hello(&self); }
struct S { }
impl T for S{ fn hello(&self){ println!("It's S!") } }
impl S { fn hi(&self) { print!( "I can do HI!" ) } }
// ジェネリクスで T を実装する型 X で as_any を実装
impl<X:'static + T> A for X { fn as_any(&self) -> &dyn std::any::Any { self as &dyn std::any::Any } }

fn main()
{
 // OOPの派生型に相当する型の元のオブジェクト
 let s: S = S{};
 // OOPの基底型に相当するトレイトオブジェクトレベルでの保持
 let rt: &dyn T = &s as &dyn T;
 // トレイトオブジェクトレベルでの関数呼び出しと自動的な派生型の関数へのディスパッチ
 rt.hello();
 // OOPで言うところの基底型から派生型へのダウンキャスト
 match rt.as_any().downcast_ref::<S>()
 {
  // ダウンキャスト済みなのでダウンキャスト先の派生型にしかない関数も呼べるはず
  Some(s) => s.hi(),
  None => println!("downcast to S was failed.")
 }
}
It's S!
I can do HI!

一応、出力としては理想を得られました。新たに trait A を用意するのは1回だけ、 S の仲間の T 派生型たちが増えてもしんどくなりません。 impl<X: 'static + T> A for XS に依存せず S やそのお仲間の T 派生型たちが増えてもこの1回の定義だけで対応できます。 'static は…つらいですね。

設計を enumunion へ変更できる場合はしちゃったらいい状況もありますが、それはそれ、適材適所でOOP継承風のパターンが楽な事はあります。と、いうわけで mopa crate の出番です🎉

// mopafy マクロ、かわいいですね!
use mopa::mopafy;
// B を mopa::Any 派生にして; 先の例までは T にしていましたが、 mopafy! マクロ内部で T だと事故が起こるので。通常の命名で問題になることはたぶんないでしょう。
trait B: mopa::Any { fn hello(&self); }
// おまじないを唱えて
mopafy!(B);
struct S { }
impl B for S{ fn hello(&self){ println!("It's S!") } }
impl S { fn hi(&self) { print!( "I can do HI!" ) } }

fn main()
{
 // OOPの派生型に相当する型の元のオブジェクト
 let s: S = S{};
 // OOPの基底型に相当するトレイトオブジェクトレベルでの保持
 let rt: &dyn B = &s as &dyn B;
 // トレイトオブジェクトレベルでの関数呼び出しと自動的な派生型の関数へのディスパッチ
 rt.hello();
 // OOPで言うところの基底型から派生型へのダウンキャスト
 match rt.downcast_ref::<S>()
 {
  // ダウンキャスト済みなのでダウンキャスト先の派生型にしかない関数も呼べるはず
  Some(s) => s.hi(),
  None => println!("downcast to S was failed.")
 }
}
It's S!
I can do HI!

😭mopa🙏 ありがとう。ナマスカール🙏

おまけ1: enum

設計を変えて trait 継承とアップキャストによるディスパッチを enum に変更する場合は、

trait T { fn hello(&self); }
struct S { }
impl T for S{ fn hello(&self){ println!("It's S!") } }
impl S { fn hi(&self) { print!( "I can do HI!" ) } }
enum E { S(S), /* 実際には T, U, V, ... 派生型をたくさん詰めます */ }

fn main()
{
 // OOPの派生型に相当する型の元のオブジェクト
 let s: S = S{};
 // OOPの基底型に相当するオブジェクトを enum へ変更
 let e: E = E::S(s);
 // OOPで言う基底型から派生型へのダウンキャストに相当する enum のディスパッチ
 match e
 {
  E::S(s) =>
  {
   // S は T を実装しているので共通関数も呼べる
   s.hello();
   // もちろん S の関数も呼べる
   s.hi();
  }
  /* 実際には T, U, V, ... 派生型ののディスパッチを書く */
 }
}

と、なります。一見これでいいじゃない的に見えるのはディスパッチ先が1種類しか無いお勉強向けの実装例だからです。enum を使うとディスパッチなしでは中身へアクセスできないので dyn TS など派生型の制約としては意味があるものの、共通関数を dyn T レベルから呼べるような機能は enum ではディスパッチ無しでは実現できません。つまり、ディスパッチ先が増えれば共通処理を呼ぶための退屈なディスパッチを大量に並べる事になります。うつくしくない…😭

おまけ2: union で無理やり reinterpret_cast しちゃったらどうなるの?

union だと Copy+Clone, unsafe が必要になるものの、型システムは巧い具合に(ある意味でバギーに)騙されたりするだろうか?なんて邪悪な事も試してみましたが、

// 基底型に相当させる enum を作り
union SuperTypeLikeUnion
{
 // ディスパッチ対象の派生型に相当する型をフィールドへいれる
 a: StructA,
 b: StructB,
}

trait HasHello { fn hello(&self); }
#[derive(Copy, Clone)] struct StructA {}
#[derive(Copy, Clone)] struct StructB {}
impl HasHello for StructA { fn hello(&self) { println!("It's A!!") } }
impl HasHello for StructB { fn hello(&self) { println!("It's B!!") } }

fn main()
{
 let a: StructA = StructA{};
 let e = SuperTypeLikeUnion{a};
 
 unsafe
 {
  // 実際のナカミは A, ぶっちぎりディスパッチも A
  let dangerous1 = &e.a as &dyn HasHello;
  dangerous1.hello();
 }

 let b: StructB = StructB{};
 let e = SuperTypeLikeUnion{b};
 
 unsafe
 {
  // 実際のナカミは B, ぶっちぎりディスパッチは A
  let dangerous2 = &e.a as &dyn HasHello;
  dangerous2.hello();
 }

}

この結果は

t's A!!
It's A!!

でした。型システムの実装を考えれば、まあ、そうですよね😅 C++ の RTTI による dynamic_cast や関数ポインターレベルの操作が何故かここで行われるはずもなく。

今回のメモのだいじなところ

  1. オブジェクト指向言語向けっぽい UML で設計したなコイツ…的なソフトウェアの設計を Rust で実装するはめになったら mopa crate を検討しましょう。
  2. Rust らしい enum ディスパッチは安全でうつくしーですが、OOPのクラスやインターフェースの継承構造を変換するには退屈なディスパッチを大量に書くことになるので適材適所で mopa りましょう。 Let's mopafy!

参考