2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

生成AI時代のRust設計 ― ドメイン知識を型に埋め込む

2
Posted at

はじめに

AIによって、コードを気軽かつ大量に生成できるようになりました。

その一方で、生成されたコードが、こちらの意図した意味やルールを本当に守っているのかを確認することが重要になっています。AIは与えられた仕様や既存コードからドメインモデルをある程度理解できます。しかしながら、細かなルールがコードや仕様に明示されていなければ、推測に頼るほかありません。

そこで有効になるのが、重要なドメインルールを型として表現し、コンパイラをガードレールとして利用する方法です。

型によってルールを表現すれば、「ルールに違反するコードを生成してもコンパイルを通せない」という設計にできます。このとき、型で守れるのは、型として表現した範囲だけになります。それでも、重要なルールを人間の記憶や注意力だけに置いておくのではなく、コンパイラが検査できる形へ移すことには大きな意味があります。

この記事では、その過程を簡単な例を使って段階的に見ていきます。

次のような状況を想像してみてください。

1000円が地面に落ちていて、
まだ誰にも拾われていない

本来ならそのまま交番へ届けたいところですが、その前に少しだけ時間をいただき、この状況を段階的に型へ変身させてみます。

目次


1. プリミティブ型で表現する

最初に、「金額」と「お金が拾われたかどうか」を別々の値として表現します。

let money: u32 = 1000;
let picked: bool = false;

人間は、このコードを次のように解釈できます。

  • moneyは金額を表す

  • 1000は1000円を表す

  • pickedは拾われたかどうかを表す

  • falseは、まだ拾われていないことを表す

しかし、コンパイラが把握しているのは、それぞれの型だけです。

money: u32
picked: bool

コンパイラにとって、moneyは符号なし整数であり、pickedは真偽値です。
1000という値についても、「1000円」ではなく、単にu32型の整数として扱います。

さらに、次のような関係もコンパイラには分かりません。

  • moneypickedは一組のデータである

  • pickedmoneyの状態を表している

  • moneyの単位は円である

この時点では、次のような状態遷移のルールも人間側にしかありません。

未拾得のお金 ──拾う──▶ 拾得済みのお金

より具体的には、次のルールがあります。

  • 拾われていないお金は、拾われた状態へ遷移できる

  • 拾われた状態から、拾われていない状態には戻れない

  • 拾われたお金をもう一度拾うことはできない

この段階でプログラム側にあるのは、u32boolという汎用データだけです。

お金であること、円であること、二つが関係していること、状態遷移は人間側の解釈に依存しています。

2. タプルにして関係を表現する

次に、金額と状態をタプルにします。

let money: (u32, bool) = (1000, false);

これによって、コンパイラは「u32boolが一組のデータである」ことを確認できるようになります。

(u32, bool)

別々の変数として表現していたときは、金額と状態が常に一緒に扱われるとは限りませんでした。しかし、タプルにすることで、少なくとも二つの値が一つのまとまりであるという関係は、プログラム側へ移りました。

ただし、(u32, bool)という型を見ただけでは、そのまとまりが何を意味するかは分かりません。

同じ型は、別の場面で次のような解釈も可能です。

(年齢, 有効かどうか)
(在庫数, 公開されているか)
(点数, 合格したかどうか)

組に名前がないため、u32boolという二人組が常に一緒にいるだけで、それが何者なのかは依然として人間側が解釈しています。

この段階では、次の意味を人間が守っています。

  • 最初のu32は円を表す

  • 二番目のboolは拾われた状態を表す

  • この組全体はお金を表す

  • 許可された状態遷移がある

3. 構造体にして概念を表現する

次に、お金をMoney構造体として定義します。

struct Money {
    yen: u32,
    picked: bool,
}

値は次のように作ります。

let money = Money {
    yen: 1000,
    picked: false,
};

ここで、プログラム側にMoneyという新しい型が生まれます。

Money

Moneyは、単なるu32でも、boolでも、(u32, bool)でもありません。

これらとは別の、名前を持った型として区別されます。

また、フィールドにも名前が付いたことで、タプルより役割が明確になりました。

money.yen
money.picked

これによって、次の構造がコード上に現れます。

  • Moneyは、ほかの型から区別された一つの概念である

  • Moneyは、数値としてyenを持つ

  • Moneyは、状態としてpickedを持つ

  • yenpickedは一つのデータとしてまとまっている

コンパイラはMoneyをほかの型から区別し、Moneyとして定義された操作だけを許可できます。

新たな型を命名すると、ドメイン上の概念をほかの概念から区別できるようになります。

構造体によって、「このデータ全体がお金である」という意味が、コード上に現れました。

しかし、一段内側を見ると、まだ改善できる点があります。

4. Newtypeで値の意味を型にする

構造体にすることで、データ全体をMoneyという概念として区別できるようになりました。

しかし、yenフィールドの型自体は依然としてu32です。

struct Money {
    yen: u32,
    picked: bool,
}

u32は、符号なし整数であることを表します。

その整数が円なのか、年齢なのか、個数なのかまでは表しません。

たとえば、次の値はすべて同じu32型です。

let yen: u32 = 1000;
let age: u32 = 20;
let item_count: u32 = 5;

人間にとっては、それぞれ次のように異なる意味を持ちます。

1000円
20歳
5個

しかし、コンパイラから見れば、すべて同じ型の整数です。

そこで、円を表す新しい型を定義します。

struct Yen(u32);

そして、Moneyu32ではなくYenを持つように変更します。

struct Money {
    amount: Yen,
    picked: bool,
}

このように、既存の型を一つのフィールドとして包み、新しい型として扱う方法は、一般にNewtypeパターンと呼ばれます。

Rust By Exampleでも、Newtypeは、プログラムに正しい種類の値が渡されたことをコンパイル時に保証するための方法として説明されています。

たとえば、円と年齢をそれぞれ別の型として定義します。

struct Yen(u32);
struct Age(u32);

どちらも内部にはu32を持っています。

しかし、YenAgeはコンパイラにとって異なる型です。

struct Money {
    amount: Yen,
    picked: bool,
}

impl Money {
    fn new(amount: Yen) -> Self {
        Self {
            amount,
            picked: false,
        }
    }
}

このnewに渡せるのはYenだけです。

let amount = Yen(1000);
let money = Money::new(amount);

一方、Ageは渡せません。

let age = Age(20);

// コンパイルエラー
let money = Money::new(age);

AgeYenの内部がどちらもu32であっても、型としては別物だからです。

Ageを渡すと引数がマッチせず、次のようなエラーになるでしょう。

expected `Yen`, found `Age`

これにより、年齢、在庫数、個数など、別の意味を持つu32を金額として誤って渡すことを防ぎやすくなります。

構造体のMoneyは、次の意味を型にしました。

このデータ全体はお金である

NewtypeのYenは、構造体の内側にある整数について、次の意味を型にしました。

この整数は円である

型の変化として見ると、次のようになります。

u32
 │
 │ 円という意味を与える
 ▼
Yen

ただし、Yen(u32)と包んだだけでは、金額として有効な値の範囲までは保証されません。

たとえば、次のようなルールがあるかもしれません。

  • 0円を許可するのか

  • 金額の上限はいくらか

  • 特定の単位でしか扱えないのか

そのようなルールも守らせたい場合は、内部の値を外部から直接構築できないようにし、検証済みの値だけを返すコンストラクタを用意できます。

struct Yen(u32);

impl Yen {
    fn new(value: u32) -> Option<Self> {
        if value == 0 {
            None
        } else {
            Some(Self(value))
        }
    }
}

Newtypeがまず区別するのは、値の大きさではなく、その値が何を意味するかです。

ここまでで、データ全体の概念と、その内部にある金額の意味は、型として表現できました。

次は、「拾われたかどうか」という状態について考えます。

5. boolは便利だが、状態遷移が緩い

現在のMoneyは、次のような構造体です。

struct Money {
    amount: Yen,
    picked: bool,
}

この構造体では、状態がboolで表現されています。

フィールドを直接変更できる設計であれば、次のような操作が可能です。

let mut money = Money {
    amount: Yen(1000),
    picked: false,
};

money.picked = true;
money.picked = false;
money.picked = true;

プログラム上では、単にboolの値を切り替えています。

しかし、人間が表現したいものは、自由な真偽値の変更ではありません。

表現したいのは、次のような一方向の状態遷移です。

未拾得のお金 ──拾う──▶ 拾得済みのお金

それぞれの変更をドメイン上の意味と対応させると、次のようになります。

false → true   正しい
まだ拾われていないものを拾う

true → false   正しくない
一度拾われたものを、拾われていない状態へ戻す

true → true    正しくない
すでに拾われたものを、もう一度拾う

このような状態遷移のルールを、boolだけに背負わせるには荷が重すぎます。

もちろん、フィールドを非公開にして、適切なメソッドからしか状態を変更できないようにすれば、直接的な書き換えは防げます。

impl Money {
    fn pick(mut self) -> Self {
        self.picked = true;
        self
    }
}

さらに、メソッド内部で現在の状態を検査し、すでに拾われている場合はエラーを返すこともできます。

impl Money {
    fn pick(mut self) -> Result<Self, &'static str> {
        if self.picked {
            return Err("すでに拾われています");
        }

        self.picked = true;
        Ok(self)
    }
}

この設計でも、実行時にルールを守らせることはできます。

しかし、拾われたかどうかの情報は、Moneyの内部にある値として保持されています。

拾われる前: Money
拾われた後: Money

どちらも型としては同じMoneyです。

そのため、関数シグネチャだけを見ると、引数と戻り値が同じ顔をしています。

fn pick(money: Money) -> Money

あるいは、エラーを返す設計でも次のようになります。

fn pick(money: Money) -> Result<Money, PickError>

これらのシグネチャだけでは、次のことを型として表現できません。

  • 引数のMoneyは、まだ拾われていないのか

  • 拾われたMoneyを渡してもよいのか

  • 戻り値のMoneyは、必ず拾得済みなのか

  • 戻り値に対して、もう一度pickを呼べるのか

構造体とNewtypeによって、データの概念や値の意味はプログラム側へ移りました。

しかし、どの状態で、どの操作が許されるかというルールは、まだ値レベルにあります。

6. 状態を値ではなく型にする

そこで、拾われたかどうかをboolではなく、名前の付いた型として表現します。

struct Unpicked;
struct Picked;

Unpickedは、まだ拾われていない状態を表します。

Pickedは、すでに拾われた状態を表します。

次に、Moneyへ状態を表す型パラメータStateを追加します。

use std::marker::PhantomData;

struct Money<State> {
    amount: Yen,
    _state: PhantomData<State>,
}

Moneyは、実際の値としてYenを持っています。

一方、状態を表すStateの値そのものを保持する必要はありません。

そこで、PhantomData<State>を使っています。

PhantomData<T>はゼロサイズ型であり、実際にはTを格納していなくても、その型がTと結び付いていることをコンパイラへ伝えるために利用できます。Rustの標準ライブラリのドキュメントでも、PhantomData<T>を追加すると、実際には値を保持していなくても、型がTを保持しているかのように扱われると説明されています。

この設計では、同じMoneyでも、状態によって型が変わります。

Money<Unpicked>
Money<Picked>

人間側の意味と対応させると、次のようになります。

Money<Unpicked> = まだ拾われていないお金
Money<Picked>   = すでに拾われたお金

この二つは、コンパイラにとって異なる型です。

boolで表現していたとき、状態は実行時の値でした。

picked: bool

型パラメータを使うと、状態そのものが型の一部になります。

Money<Unpicked>
Money<Picked>

これにより、人間だけが区別していた「拾われる前」と「拾われた後」を、コンパイラも区別できるようになります。

このように、オブジェクトの現在の状態に関する情報を型へ組み込む設計は、一般にTypestateパターンと呼ばれます。The Embedded Rust Bookでは、Typestateはオブジェクトの現在の状態に関する情報を、そのオブジェクトの型へ符号化する考え方として説明されています。

7. 状態遷移をメソッドの型で表現する

次に、「拾う」という操作を定義します。

拾うことができるのは、まだ拾われていないお金だけです。

そのため、pickメソッドはMoney<Unpicked>にだけ定義します。

impl Money<Unpicked> {
    fn new(amount: Yen) -> Self {
        Self {
            amount,
            _state: PhantomData,
        }
    }

    fn pick(self) -> Money<Picked> {
        Money {
            amount: self.amount,
            _state: PhantomData,
        }
    }
}

newは、まだ拾われていないお金を生成します。

fn new(amount: Yen) -> Money<Unpicked>

pickは、未拾得のお金を受け取り、拾得済みのお金を返します。

fn pick(self) -> Money<Picked>

型の変換として見ると、次のようになります。

Money<Unpicked> → Money<Picked>

人間側の言葉にすると、次のストーリーになります。

まだ拾われていないお金を拾うと、
拾われたお金になる

ここで、人間側のストーリーと、プログラム側の型変換が対応します。

boolを使っていたときの状態変更は、値の変更でした。

false → true

Typestateで表現した場合は、ドメイン上許可された状態遷移が、型の変換として現れます。

Money<Unpicked> → Money<Picked>

また、pickselfを値として受け取っています。

fn pick(self) -> Money<Picked>

&self&mut selfではなく、selfです。

そのため、pickは呼び出し元のMoney<Unpicked>の所有権を受け取ります。Rustでは、値を関数へ値渡しすると所有権がムーブされ、元の所有者はその値を使えなくなります。

let money = Money::<Unpicked>::new(Yen(1000));

let picked_money = money.pick();

moneypickへムーブされたため、以後は使用できません。

代わりに、拾得済みの状態を表すMoney<Picked>が返されます。

Money<Unpicked>が消費される
              ↓
Money<Picked>として生まれ変わる

少し大げさに言えば、型レベルの輪廻転生です。

古い状態の値は使用できなくなり、新しい状態の値だけが残ります。

8. 許されない操作はコンパイルエラーにする

未拾得の1000円を生成し、それを拾ってみます。

let money = Money::<Unpicked>::new(Yen(1000));

let picked_money: Money<Picked> = money.pick();

picked_moneyは、すでに拾われたお金です。

ここで、もう一度pickを呼んでみます。

let picked_again = picked_money.pick();

しかし、このコードはコンパイルできません。

pickメソッドは、Money<Unpicked>にしか定義されていないからです。

impl Money<Unpicked> {
    fn pick(self) -> Money<Picked> {
        // ...
    }
}

Money<Picked>には、pickメソッドが存在しません。

つまり、次のドメインルールが型によって守られています。

拾われたお金を、もう一度拾うことはできない

このルールは、コメントとして注意書きされているだけではありません。

// すでに拾われている場合はpickを呼ばないこと

呼び出し側が注意して守るルールでもありません。

テストで特定のケースだけを確認するルールでもありません。

Money<Picked>という型に、そもそもpickという操作が用意されていないのです。

Money<Unpicked>
    └── pickできる

Money<Picked>
    └── pickできない

許されない操作を実行時に失敗させるのではなく、プログラムの候補から除外しています。

なお、この保証を外部コードに対して成立させるには、構造体のフィールドを非公開にし、正規のコンストラクタと状態遷移メソッドだけを公開することが重要です。

フィールドを自由に書き換えたり、任意の状態を直接構築できるAPIを公開したりすれば、型で設けた入口を迂回できてしまいます。

型だけでなく、モジュール境界を含めて設計する必要があります。

9. 人間側の意味がプログラム側へ移る過程

ここまでの変化を整理します。

型の種類 表現 プログラム側で検査できること 主に人間側が守ること
プリミティブ型 u32bool 汎用的な型としての制約 値の意味、二つの関係、状態遷移
タプル (u32, bool) 二つの値が一組であること 組全体の意味、各要素の役割、状態遷移
構造体 Money { yen, picked } 名前の付いた一つの概念として区別すること フィールド型の意味、状態ごとに許される操作
Newtype Yen(u32) 円と、年齢や個数など別の整数を区別すること 有効な金額の範囲、そのルールの背景
Typestate Money<Unpicked>Money<Picked> 状態の違いと、許可された状態遷移 その状態遷移を採用する理由や現実世界の背景

型の変化だけを並べると、次のようになります。

u32 と bool
    │
    │ 二つの値を一組にする
    ▼
(u32, bool)
    │
    │ まとまりに名前を与える
    ▼
Money {
    yen: u32,
    picked: bool
}
    │
    │ 金額の意味を型として区別する
    ▼
Money {
    amount: Yen,
    picked: bool
}
    │
    │ 状態を型として区別する
    ▼
Money<Unpicked> ──pick──▶ Money<Picked>

最初は、人間だけが次のストーリーを理解していました。

1000円が地面に落ちている

まだ誰にも拾われていない

誰かが拾うと、拾得済みになる

拾得済みのお金を、
もう一度拾うことはできない

最終的には、その一部が型として表現されています。

Yen
Money<Unpicked>
Money<Picked>

Money<Unpicked> ──pick──▶ Money<Picked>

プログラム側から見れば、これは型の変換です。

Money<Unpicked>から
Money<Picked>への変換

人間側から見れば、次の物語です。

落ちていたお金が拾われた

この二つが対応することで、コンパイラにドメインモデルの一部を守らせることができます。

まとめ

AIが生成されたコードが意味やルールを守っているのかが重要になり、プロンプト、テスト、型は、それぞれ異なる立場からコードの意味を守っています。

  • プロンプトは、どのようなコードを生成してほしいかを伝える

  • テストは、具体的な入力に対する振る舞いを確認する

  • 型は、許されない値や操作をプログラムの候補から除外する

プロンプトで、次のように指示することはできます。

拾われたお金を、もう一度拾わないでください

テストで、次の振る舞いを確認することもできます。

拾われたお金をもう一度拾おうとすると、
エラーになること

Typestateとして型に表現すれば、そもそも二度目のpickを呼び出せない設計にできます。

Money<Picked>にはpickが存在しない

同様に、金額をNewtypeとして表現すれば、年齢や個数を誤って金額として渡すコードもコンパイル時に排除できます。

YenとAgeは別の型

型を細かくする目的は、人間側にしかなかった意味を、コンパイラが扱える形へ少しずつ移すことです。

データのまとまり
    ↓
ドメイン上の概念
    ↓
値の意味
    ↓
状態
    ↓
許可された状態遷移

この過程によって、生成されたコードが重要なルールから外れにくくなります。

仮にAIが誤ったコードを生成しても、型として表現されたルールに違反していれば、コンパイラに止められます。

AIがコードを生成する
        ↓
型検査を行う
        ↓
ドメインルールに違反したコードを除外する

型は、AIが必ず正しいコードを書くことを保証するものではありません。

しかし、誤ったコードが通過できる範囲を狭くすることはできます。

PS

人間側にあるルールのうち、型として表現できるものをプログラム側へ移せば、ドメインモデルに反するコードをコンパイル時に排除できます。

これが、重要なものを細かく型にする設計の狙いです。

AIエージェントに作業を任せる場合も、重要な概念、値の意味、状態遷移を先に型として用意しておけば、エージェントはコンパイラの恩恵を受けながらコードを生成できます。

人間が後からレビューする場合にも、次のような関数シグネチャがあれば、

fn pick(self) -> Money<Picked>

実装の中身をすべて読む前に、

未拾得のお金を消費し、
拾得済みのお金を返す操作

という意味を読み取れます。

型は、コンパイラへ与える制約であると同時に、人間とAIが共有できる、実行可能なドメインモデルでもあります。

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?