プログラミング言語で自然言語の様に会話する方策 第1回
Copyright (C) 2024 Takym.
概要
前回の記事で「この題材を選んだ深い理由はありません。」と書いたにも拘わらず、「プログラミング言語で会話する動機」が長文だった事に疑問を感じた方もいるかもしれません。他の題材でもこれと同じくらいの動機があって、その中から選んだ理由は特に無いという事です。それでは、本題に入りましょう。今回はソースコードに関する注意点と基本構造について解説します。
ソースコードと注意点
この連載(シリーズ)で掲載するソースコードの最新の全文は、https://github.com/Takym/primers/tree/master/src/ProgrammingLanguageTalking に公開してあります。これらのソースコードの著作権は保護されており、MITライセンスの規定に従って利用する必要があります。規定の内容を簡単に要約しますと、コピーする際は必ず著作権表示を含めなければならず、且つ無保証で提供されるという事です。ただし、必ず英語の原文をよく読み、深く理解してから使う様にしてください。この連載では、ブログ記事内に全てのソースコードを掲載しません。本質的に重要な部分を切り出して掲載します。解説に影響しないデバッグ用のコードは全て省きます。手元で実行して確認したい場合は、前述したサイトからソースコードをダウンロードする必要があります。
基本構造
基本的な型の一覧を下記に再掲します。
クラス | 和訳 | 説明 |
---|---|---|
Word |
単語 | 下記の全てのクラスの基底の型。単語間の論理関係を表す為の基本的な機能を提供する。 |
Agent |
人物 | 会話文を表現する利用者が用いる型。継承して使う事を前提に設計する。 |
Context |
文脈 | 会話の流れや過去の記録、その他の関連するデータを保持する。 |
Decision |
意思決定 | 思考の結果を記述するオブジェクト。メッセージの送受信にも使われる。 |
God |
神 | 便利な機能をまとめた型。本当は良くないが、コードの整理が面倒な時にこれを使う事にする。 |
上記の神は、以前の神クラスの記事(この記事を本連載に含める事はしませんが)を参考にしました。本質的には全くの無関係なクラスなので、あまり紹介する事は無いと思いますが、一応、存在だけは記載しておきます。
Word
型
先ずは、基本型 Word
を定義します。実際にはこれ以外の型でも単語を表す事ができる様に実装していく予定です。この連載で定義する型は Word
を継承して実装します。この型は下記の様な構造を持つ抽象型です。
public abstract class Word
{
public virtual string? DisplayName => null;
public override string ToString()
{
/* ... 中略(デバッグ用のコード) ... */
}
}
DisplayName
プロパティはデバッグ用の表示名です。つまり、この型自体は何もしません。obj is Word
の様に、オブジェクトが「単語」であるかどうかを判定する為だけの役割を持ちます。今後の改良次第では機能が追加されていくかもしれません。
Context
型
次に、Context
を定義します。主に会話の文脈を維持する為に使います。副作用の無いコードにしたい時は、Context
を無視する様にしてください。Context
は、下記の様なプロパティを持ちます。
public class Context : Word
{
public override string? DisplayName { get; } // デバッグ用の表示名。
public RootContext Root { get; } // 根文脈への参照。
public Context? Parent { get; } // 親文脈への参照。
public ConcurrentDictionary<object, Decision?> Decisions { get; } // 意思決定・会話の過去ログを保持する辞書。
/* ... 後略 ... */
Context
には親子関係があります。ここでは、親文脈・子文脈と呼び分ける事にします。子文脈から親文脈の参照を得る事はできますが、その逆はできません。片方向の参照でなければ、親子に別ける必要が無くなってしまうからです。子文脈は、変更を親文脈へ影響させない為に使います。また、子文脈同士で互いに影響を与える事はできません。この二つを実現する為に、親文脈は子文脈への参照を持たないのです。常に親文脈を持たない文脈も存在します。これを根文脈と呼ぶ事にします。全ての文脈は根文脈への参照を保持します。根文脈自身は、自分自身への参照を根文脈として保持します。根文脈は特別な型として定義します。
public sealed class RootContext : Context
{
public RootContext(string? displayName)
: base(displayName, null!) { }
}
根文脈以外の文脈は必ず親文脈への参照を保持しなければならないので、文脈の基本型の初期化子(コンストラクタ)でエラー判定をする必要があります。
/* ... 前略 ... */
public Context(string? displayName, Context parent)
{
if (this is RootContext root) {
Debug.Assert(parent is null);
this.Root = root;
} else {
ArgumentNullException.ThrowIfNull(parent);
this.Root = parent.Root;
}
this.DisplayName = displayName;
this.Parent = parent;
this.Decisions = new();
}
/* ... 後略 ... */
もし自分自身が根文脈であるならば、自分自身を根文脈として設定します。ここで、引数から渡された親文脈が null
であるかどうかを判定していません。RootContext
型は必ず parent
に null!
を設定するからです。根文脈以外の型である場合、parent
に null
が渡されると例外を発生させます。また、根文脈は親文脈から取得するので、引数として渡す必要はありません。エラー判定が終わったら、残りのプロパティを初期化します。文脈のインスタンス生成はこれで終了します。次に、子文脈を生成するには、任意の文脈型の初期化子を直接呼び出すか、若しくは下記の CreateChild
関数を介して生成します。CreateChild
関数を上書きする事で、子文脈の型を適切なものに設定したり、追加の初期化処理を実行したりする事ができます。ただし、子文脈が作られる時は、必ず CreateChild
が呼び出される訳では無いという事に留意する必要があります。また、親文脈が子文脈の参照を保持する様な使い方は想定していません。
/* ... 前略 ... */
public virtual Context CreateChild(string? displayName)
=> new(displayName, this);
/* ... 後略 ... */
ここまでは文脈の構造について解説してきました。具体的に文脈がどの様な情報を保持しているのか解説します。全ての文脈オブジェクトは、次の節で説明する Decision
型のオブジェクト(意思決定オブジェクト)を、任意のオブジェクトをキーとする辞書に入れて保持します。これが会話の過去ログを表しているのですが、全ての意思決定オブジェクトを保持する訳ではありません。重要だと思われるもののみが取捨選択されて文脈の内部に積み上げられていきます。過去の意思決定を参照する事で、会話の流れを把握できる様にしています。それ以外の情報を保持する必要がある場合は、文脈型の派生型を作って任意の情報を保持させる事もできます。ConcurrentDictionary
は非同期処理向けの辞書型であり、今回は非同期的に処理させる訳ではありませんが、筆者が慣れている都合上、この型を選びました。今後の非同期化の拡張に有用にはなると思います。Decisions
プロパティをそのまま使ってもいいのですが、親文脈からの情報取得を行う等の使い易さを考慮して、基本文脈型では下記の様な便利な機能も実装しています。
/* ... 前略 ... */
public Decision? this[object key]
{
get => this.GetDecision(key);
set => this.Decisions.AddOrUpdate(
key,
(_, value) => value,
(_, _, value) => value,
value
);
}
/* ... 中略 ... */
public virtual Decision? GetDecision(object key)
{
ArgumentNullException.ThrowIfNull(key);
if (this.Decisions.TryGetValue(key, out var result)) {
return result;
}
return this.Parent?.GetDecision(key);
}
ところで、機能拡張を行う場合は、派生型を作るのではなく拡張関数を定義する事によって、任意の文脈型にその機能を適用できる様にしてください。これは、オブジェクト指向の継承の原則でもあります。
Decision
型
次に、Decision
を定義します。意思決定を表します。文章が思考の結果であるという事に着目してこの名称にしました。Message
でも良かったかもしれません。しかし、後述する Agent
と Context
と Decision
の組み合わせを一つのメッセージとして考えていますので、Decision
という名称が選ばれました。メッセージの送信は下記の委譲型(デリゲート)に適合する関数で行われます。
public delegate Decision? SendMessageFunc(Agent sender, Context context, Decision decision);
意思決定型は継承されて使われる事を前提に作っています。本質的には Receiver
プロパティと SendMessage
プロパティのみが重要です。メッセージを返信する時は、基本的には SendMessage
を使います。Receiver
を使っても構いませんが、文脈が維持されなくなる可能性もあります。実は文脈型以外の部分でも文脈を維持させる為の仕組みを用意している訳です。副作用の無いコードを書き易くしたり、文脈を維持する為の手段を選択できたりする事で柔軟性を向上させています。Context
ではなく SendMessage
を介した文脈の維持は副作用の減少に寄与すると思います。初期化子はプロパティの初期化を行っているだけです。文脈型よりは単純な構造になっていますね。
public class Decision : Word
{
public override string? DisplayName { get; } // デバッグ用の表示名。
public Agent Receiver { get; } // メッセージの受信者(受信元)。
public SendMessageFunc SendMessage { get; } // メッセージの返送に使う関数への参照。
public Decision(string? displayName, Agent receiver, SendMessageFunc sendMessage)
{
ArgumentNullException.ThrowIfNull(receiver );
ArgumentNullException.ThrowIfNull(sendMessage);
this.DisplayName = displayName;
this.Receiver = receiver;
this.SendMessage = sendMessage;
}
public Decision(Agent receiver)
: this(receiver.DisplayName, receiver, receiver.MakeDecision) { }
/* ... 後略 ... */
閑話
ところで、受信者は、受信先でしょうか?受信元でしょうか?「元」は行為が発生した所を指し、「先」は行為が影響する所を指します。例えば、「送信元」は送信する側を指し、「送信先」は送信を受ける側を指しますよね。この定義に倣えば、「受信元」は受信する側、「受信先」は受信を受ける側になる訳です。よって、受信者は受信元になります。受信元であると同時に送信先でもあります。因みに、送信者は送信元・受信先です。送信も受信も両方とも能動的な言い回しです。受動的な言い回しにしたかったら、被送信や被受信を使えば良いのでしょうが、あまり一般的ではありませんね。助動詞の「する」を使う代わりに「される」を使えば良いかもしれませんが、これもこれでややこしいですね。それにしても同じ漢字が多くて紛らわしいですよね。よく誤用している人が多いので注意書きとして書いて置きました。
閑話休題
Agent
型
最後に、実際に会話文を記述する為の型として Agent
を定義します。こちらも Word
と同じく抽象型になります。また、Decision
と同じく文脈型よりは単純です。英語の「agent」は「代理人」などの意味を持ちます。あなたの会話をコンピュータシステムが代理して行う様子を表しています。しかし、ここでは単に「人物」と呼称します。ただし、人物そのものを表している訳ではないという事は忘れないでください。Person
/Human
クラスではなく Agent
クラスです。会話文は OnMessageReceived
内に記述します。メッセージを受け取ってから、新たなメッセージを作る訳です。
public abstract class Agent : Word
{
public Decision? MakeDecision(Agent sender, Context context, Decision decision)
{
/* ... 中略 ...*/
}
protected abstract Decision? OnMessageReceived(Agent sender, Context context, Decision decision);
}
MakeDecision
は OnMessageReceived
を内部で呼び出す必要があります。具体的にどの様な処理を経て OnMessageReceived
を呼び出すべきか考察していきましょう。利便性の為に引数の null
検証を行うべきでしょう。これにより、OnMessageReceived
では null
検証する必要が無くなります。戻り値は仮に null
としておきます。
public Decision? MakeDecision(Agent sender, Context context, Decision decision)
{
+ ArgumentNullException.ThrowIfNull(sender );
+ ArgumentNullException.ThrowIfNull(context );
+ ArgumentNullException.ThrowIfNull(decision);
return null;
}
次に、送信者が自分自身になる事について考えてみます。もし、送信者が自分自身であるならば、返信する時に自分自身へメッセージを送信する事になります。これでは、文脈指定や検証を正しく行っていない場合、無限再帰に陥る可能性があります。よって、この様な問題が発生しない様に、sender
が this
にならない様に制限を掛ける事にします。
public Decision? MakeDecision(Agent sender, Context context, Decision decision)
{
ArgumentNullException.ThrowIfNull(sender );
ArgumentNullException.ThrowIfNull(context );
ArgumentNullException.ThrowIfNull(decision);
+ if (sender == this) {
+ throw new ArgumentException($"指定された送信者(sender 引数)は自分自身です。({this})", nameof(sender));
+ }
return null;
}
最後に、渡された引数を全て OnMessageReceived
へ流してから呼び出します。そしてその戻り値を MakeDecision
の呼び出し元へ返します。
public Decision? MakeDecision(Agent sender, Context context, Decision decision)
{
ArgumentNullException.ThrowIfNull(sender );
ArgumentNullException.ThrowIfNull(context );
ArgumentNullException.ThrowIfNull(decision);
if (sender == this) {
throw new ArgumentException($"指定された送信者(sender 引数)は自分自身です。({this})", nameof(sender));
}
- return null;
+ return this.OnMessageReceived(sender, context, decision);
}
これで MakeDecision
は完成です。外部から OnMessageReceived
を呼び出せると制限を回避できてしまうので注意しましょう。実はこの設計には大きな問題が一つ隠れています。会話の最初が定義されていない事です。会話が始まる時は、送信者は誰も居ませんし、意思決定も為されていません。文脈自体は新たに作られるので、ここでは問題になりません。にも拘らず、全ての null
は排除されています。というわけで、擬似的に null
を表す型を定義します。この型はシングルトン(単一実体・単一インスタンス)にします。厳密には異なりますが、番兵ノードと似た発想です。尚、null
そのものを受け付けるのは不具合の原因になるので止めましょう。
// 空の人物を表す型。
public sealed class NullAgent : Agent
{
private static readonly NullAgent _inst = new();
public static NullAgent Instance => _inst;
// 外部から呼び出せない様にする事で単一性を担保する。
private NullAgent() { }
protected override Decision OnMessageReceived(Agent sender, Context context, Decision decision)
=> NullDecision.Instance; // 空の意思決定を返す。
// 他の機能は持たない。
}
// 空の意思決定を表す型。
public sealed class NullDecision : Decision
{
private static readonly NullDecision _inst = new();
public static NullDecision Instance => _inst;
// 外部から呼び出せない様にする事で単一性を担保する。
// また、受信者は誰も居ない。
private NullDecision() : base(NullAgent.Instance) { }
// 他の機能は持たない。
}
閑話
先程、「Person
/Human
クラス」と Person
と Human
を併記しました。英語の「person」は「法的な人格権を持つ人間」や「社会的な関係における人間」を意味します。「person」が個人だけではなく法人を含む事もあるのはこういう訳です。一方で、英語の「human」は「動物としての人間・ヒト」を意味します。「ホモ・サピエンス・サピエンス」を指すのか「ヒト属」を指すのかは曖昧だと思います。もしかすると、地球上に生息する猿の一種としての人だけではなく、全ての知的生命体を含むのかもしれませんが、そこまで英語に詳しい訳では無いので、はっきりとした事は言えません。ところで、「ホモ・サピエンス」はラテン語で「賢い人間、賢人」を意味するそうです。全ての人が賢い訳ではなく、また、滅んだ原人(その当時の環境に適さなかった)が賢くなかった事が証明されている訳でもありませんが、賢人と言うそうです。いや、もしかすると、他の動物より賢いという意味かもしれません。それであれば、実態に即していますね。個人的には現生人類の全員を含めて自画自賛していると解釈しています。まあ、単なる学名です。
閑話休題
今回はここまでとします。神クラスやエントリポイント(開始地点)の解説は重要ではないので省きます。今回の記事までの完全なソースコードは https://github.com/Takym/primers/tree/talking/2024-12-03/src/ProgrammingLanguageTalking で確認する事ができます。MITライセンスの下で配布していますので、ご注意ください。次回以降からは、実際にプログラミング言語で会話文を表現する試みを始めて行きます。