将棋アプリを作りたい #4 - 駒の基本機能を定義 ~駒クラスは不変であるべきか~
最初に組んだコード
/packages/core/entities/Piece.tsに以下のコードを書きました。
import type { UUID } from "crypto";
import { logger } from "../../tools/index.js";
import type { PieceKind, Side } from "./types.js";
import { pieceValidator } from "./validators/PieceValidator.js";
export class ShogiPiece {
public readonly id: UUID;
constructor(
public readonly side: Side,
public readonly kind: PieceKind,
public readonly isPromoted: boolean = false
) {
pieceValidator(isPromoted, kind);
this.id = crypto.randomUUID();
}
public promote = (): ShogiPiece => {
if (this.isPromoted) {
logger.warn(`${this.kind}は既に成っています。`);
return this;
}
return new ShogiPiece(this.side, this.kind, true);
}
public changeSide = (): ShogiPiece => {
logger.trace(`${this.side}の${this.kind}が対局相手に渡しました。`);
const nextSide: Side = this.side === "Sente" ? "Gote" : "Sente";
return new ShogiPiece(nextSide, this.kind, false);
}
}
pieceValidatorの実装は以下です。
import type { PieceKind } from "../types.js";
import { NoPromotablePieceSchema } from "./types.js";
export const pieceValidator = (isPromoted: boolean, kind: PieceKind) => {
if (isPromoted) {
const isNoPromotable = NoPromotablePieceSchema.safeParse(kind).success;
if (isNoPromotable) {
throw new Error(`${kind} cannot be promoted.`);
}
}
}
まだ機能が足りていませんが、ざっくりとした仕組みは以上です。
UUIDの破棄による再現性の低下
外からUUIDを注入するのは少し手間だという理由で、初期化時に自動生成するように書いたたのですが...
export class ShogiPiece {
public readonly id: UUID;
constructor() {
this.id = crypto.randomUUID();
}
例えば、歩が成った時、角が取られた時などにIDが変わります。この時にどう追跡するか、追跡しないのかなどの判断がまだされていませんでした。
そこで、同一のインスタンスを使いまわすか、状態が変わる際にクラスを作り直すかの2択で考えました。
都度クラス生成(インスタンスはなるべく不変的)
成る・取られるなどが発生した際、クラス内の値を変えようかとも考えましたが、計算ミスなどのバグが入りやすく、扱いにくい面があります。
なるべく駒は過去を知らない形にしたいですし、駒に履歴が残ったり駒が自分から変化するようなコードは少し扱いにくいという発想です。
このとき、新しい駒に既存のIDを渡すときに、重複を上手く扱えるかが重要になります。
同じクラスを使いまわす(インスタンスは可変的)
成る・取られるなどが発生した場合、内部の状態だけを変えることは直観的で、それに従えばインスタンスが可変になります。
このとき、冒頭のコードのようなインスタンス生成は使えない代わりに、ID重複などのことは考えずに済みます。
普遍的で済ませたい
インスタンスを可変的にしておけば、インスタンス生成のコストはなくなりますが、内部状態がどうであったかの捕捉が難しくなると考えました。
内部状態を変えるロジックも、可逆性の面でも厄介になるため、「この種類の駒がここにあった」ということだけを記録しておいた方が可逆性は保てます。
そのため、普遍的なインスタンスのみ保持し、将来的に履歴は棋譜的な方法で管理するのがバグを起こしにくいと考えました。
改修後のコード
idを注入可能にし、既存の駒のidが引き継げる形にしました。
id重複について考えましたが、過去の駒を保持することはないため、ガベージコレクションが正常に働くと考えています。
過去の駒がなくなっても、将来的に棋譜の概念を取り入れることを考えているため問題になりません。
export class ShogiPiece {
constructor(
public readonly side: Side,
public readonly kind: PieceKind,
public readonly isPromoted: boolean = false,
public readonly id: UUID = crypto.randomUUID()
) {
pieceValidator(isPromoted, kind);
}
public promote = (): ShogiPiece => {
if (this.isPromoted) {
logger.warn(`${this.kind}は既に成っています。`);
return this;
}
return new ShogiPiece(this.side, this.kind, true, this.id);
}
public changeSide = (): ShogiPiece => {
logger.trace(`${this.side}の${this.kind}が対局相手に渡しました。`);
const nextSide: Side = this.side === "Sente" ? "Gote" : "Sente";
return new ShogiPiece(nextSide, this.kind, false, this.id);
}
}
まとめ
パフォーマンス的にはインスタンス生成を抑えた方がよさそうに感じますが、
状態変化の多い将棋では駒の種類が不変であることを優先し、履歴は棋譜に任せるのがよいと判断しました。
Discussion