オブジェクト指向を「用語と定義」として捉え直す ― OOPの原則の手前にあるもの

オブジェクト指向を「用語と定義」として捉え直す ― OOPの原則の手前にあるもの

⚠️ 免責: この記事はakaのアイデアをもとに生成AIに文章の作成を手伝ってもらいました.

どうもakaです.

前の記事で「OOP, TDD, DDDについてはまた別記事で作ろうと思います.」と書いていたので, 今回はOOPとDDDについて書きました. ただし, これは教科書的なOOP解説ではなく, 私的解釈 です.

この記事では, 「オブジェクト=用語」という軸でOOPを捉え直してみる. 「OOPってそういう見方もできるのか」と思ってもらえたら嬉しいです.

プログラミングとは用語とその定義を記す行為

OOPの話に入る前に, まず一つ前提を共有させてほしい.

プログラミングとは用語とその定義(その用語は何なのか)を記す行為だ.

プログラミング 言語 なのだから, 当たり前といえば当たり前だ. しかし実際には, 多くの人がプログラミングを「処理を書くこと」として捉えていて, 「用語とその定義を記す行為」としてはあまり捉えていない. もちろん計算, 制御, 状態遷移, I/O といった側面もある. しかしそれらをコードに落とすとき, 私たちは関数名やクラス名という形で用語と用語の定義を書いている.

プログラムの中では, 命名を行い, 私たちが実装した通りに振る舞う. 自分が決めた用語が, 自分が決めた意味で動く. 具体的に見てみよう.

// In an order management system for an e-commerce site
class Order {
    private final List<OrderLine> lines;
    private final Money totalAmount;

    public void confirm() { ... }
    public void cancel() { ... }
}

ここでは Order という 用語 を立てた. それは, 明細を持ち, 合計金額を持ち, 確定でき, キャンセルできる ―― これが Order定義 だ. 用語が「何と呼ぶか」を決め, 定義が「それは何か」を決める.

コード上では, 用語と定義はいくつかの形で現れる.

そして, 用語と定義の関係は見る粒度によって違う. 先ほどの Order をもう一度見てみよう.

クラスレイヤー:
  用語 = Order
  定義 = lines, totalAmount, confirm(), cancel()

メソッドレイヤー:
  用語 = Order.confirm
  定義 = ステータスを確定済みに変える, 確定日時を記録する, ...

クラスレイヤーでは Order が用語, そのメソッドやプロパティの全体が定義だ. 一段降りてメソッドレイヤーでは, Order.confirm が用語, その中の処理が定義になる. 上のレイヤーの「定義の一部」が, 下のレイヤーでは「用語」になる. どの粒度で見ても, 用語と定義の対がある.

では, なぜこの対応にこだわる必要があるのか. それは単なるコーディング作法ではなく, ソフトウェア品質の基盤 になるからだ. 適切な用語とその定義には, 少なくとも4つの力がある.

  1. 認知の圧縮. order.confirm() という用語があれば, 定義の詳細を毎回意識せずに「注文を確定する」という行為として扱える. 複雑さを定義の中に閉じ込め, 用語だけで会話できる.
  2. 推論の土台. 適切な用語は, 次の設計を推論しやすくする. 「Orderconfirm() があるなら cancel() もあるべきでは?」という発想が自然に出てくる.
  3. 共有の基盤. 用語があれば, チームで同じ言葉で同じ概念を指しやすい. 「あの処理」「あれ」ではなく, 明確な名前で会話できる.
  4. 変更耐性. 用語と定義が対応していれば, 変更の影響範囲が予測しやすくなる. 「注文確定のロジックを変えたい」なら Order.confirm() の定義を見に行けばよい, というガイドになる.

中でも保守性への影響は大きい.

用語は適切な粒度で切り, 定義はその粒度に合わせる必要がある. 粗すぎる用語は, 用語としての機能を果たさなくなる. 先ほどの Order は, 「確定またはキャンセルできる注文」という一つの段階に絞った定義だった. しかし, もしこの Order にあらゆる段階の責務を詰め込んだらどうなるか. カートに商品を追加する段階, 決済が通った確定済みの段階, 配送中や届いた後の段階 ―― これらをすべて一つの Order の定義に押し込むとどうなるか.

order.addItem(item);       // カート段階でしか呼べないはず
order.confirm();           // 確定前にしか意味がないはず
order.requestRefund();     // 履行後にしか意味がないはず

どのメソッドをいつ呼ぶべきなのか, コードを読んだだけではわからない. 新しく参加した開発者は, 内部のステータスフラグを追って初めて「ああ, この Order はそういう意味か」と理解する. もし最初から Cart, ConfirmedOrder, CompletedOrder と用語を分けていれば, 用語だけでどのような状態なのか, 何ができそうかが伝わる. これが, 用語と定義の対応が保守性に直結する理由だ.

ここで一つ注意しておきたい. この前提はOOP固有の話ではない. 関数型だろうと手続き型だろうと, プログラマーは関数名や型名という形で用語を作り, その定義を書いている. ただし, パラダイムが変われば用語の切り方も定義の形も変わる.

たとえば関数型では, 代数的データ型で用語を立て, その定義を与える.

-- "OrderStatus" means one of: InCart, Confirmed, or Completed
data OrderStatus = InCart | Confirmed | Completed

-- "Order" is order lines plus a status
data Order = Order { orderLines :: [OrderLine], status :: OrderStatus }

-- "confirm" transforms an order into a confirmed order
confirm :: Order -> Order
confirm order = order { status = Confirmed }

data OrderStatus = InCart | Confirmed | Completed では, OrderStatus が用語, 「カート中・確定済み・完了のいずれかである」がその定義だ. OOPではクラスが用語を導入し, プロパティとメソッドで定義を与える. 関数型では型が用語を導入し, その上の関数で定義を与える. 同じ概念を, 異なる道具で切り出している.

OOP側のコードと並べてみよう.

// OOP-style: the order object owns state and changes itself
class Order {
    private OrderStatus status;

    public void confirm() {
        this.status = OrderStatus.CONFIRMED;
    }
}

OOP的には Order.confirm の定義は「注文が自らの状態を変える」こと. 関数型的には confirm の定義は「確定済みの新しい注文を生成する変換」. 定義の形は異なる. しかし, どちらも Orderconfirm という用語を立て, それぞれに定義を与えている ことに変わりはない.

OOPとDDDの位置づけ

プログラミングが用語とその定義を記す行為だとして, 次の問いはこうなる. どんな用語を立て, どう定義すればよいのか?

「注文」という用語を立てるとして, その定義に何を含めるべきか. どんな粒度で切るべきか. これは自由度が高すぎて, 「用語と定義を書け」だけでは答えが出ない. そこで道具として出てくるのが OOPDDD だ.

レイヤー1: プログラミングとは用語とその定義を記すこと(パラダイムに依存しない原理)
    「では, どんな用語を立て, どう定義するのか?」
レイヤー2: OOPやDDDが道具として使える
    - OOP: 認識を用語と定義として書きやすい表現形式
    - DDD: 用語の向きをドメインに定める考え方

ここからは, このレイヤー2 ―― OOPとDDDが用語と定義にどう役立つのか ―― を掘り下げていく.

OOPが用語と定義を書くのに向いている理由

OOP(オブジェクト指向プログラミング)は, 一般的には「データとそれに関連する操作をオブジェクトとしてまとめ, オブジェクト同士のやり取りでプログラムを構成するパラダイム」と説明される. このオブジェクトという仕組みが, なぜ用語と定義を書くのに向いているのか. 理由は大きく3つある.

  1. オブジェクトとして, 認識のまとまりをそのまま用語にしやすいこと
  2. カプセル化や抽象化などの仕組みで, 用語と定義の境界を保ちやすいこと
  3. 主語と動詞の関係を, コード上に自然に固定しやすいこと

オブジェクト=認識を用語にしたもの

一般的な定義では, オブジェクトは「データと操作をまとめたもの」だ. しかし私にとってオブジェクトとは, 人間の認識をコード上の用語として固定したもの だ.

ここで重要なのは, 「実世界をそのまま写している」のではないということだ. OOPが写しているのは 実世界をどう切り出したかという結果 だ. 人間が現実を見て, 「ここに境界がある」「これは一つのまとまりだ」と捉えた, その切り出し方をコードに落としている. 同じ実世界でも, システムの目的が変われば切り出し方は変わる. ここで「認識」という言葉を使っているのは, この切り出し方が客観的に一意に決まるものではないからだ. 何をひとまとまりとするか, どこに境界を引くかは, 見ている人間の立場と目的によって変わる.

たとえば, ペットサロンの予約管理システムを作るとする.

// "Dog" in a pet salon reservation system
class Dog {
    private final Name name;
    private final Owner owner;
    private final Breed breed;
}

この Dog は生物学的な犬の完全なモデルではない. 「ペットサロンの予約システムにとって犬とは何か」―― 名前があり, 飼い主がいて, 犬種がある. 内臓の構造も遺伝子情報も bark() もない. それはこのシステムの関心事ではないからだ.

つまり, オブジェクトという用語は実在物のコピーではなく, 目的のために切り出した認識単位 だ. そしてクラスの中身が, その認識の定義になる.

OOPの原則は手段であって本質ではない

この見方をすると, OOPの原則の位置づけも少し違って見える. レースゲームを例にしてみよう.

// "Car" in a racing game
interface Car {
    void accelerate();
    void brake();
    void steer(Direction direction);
}

// "Player": someone who drives a car in the race
class Player {
    private final PlayerId id;
    private Car car;

    public void drive(Direction direction) {
        car.accelerate();
        car.steer(direction);
    }
}

Car には, 車検の有効期限も保険の契約内容も出てこない. レースゲームにとっては, 加速とブレーキとステアリングができれば「車」だからだ. そして PlayerCar の定義の内部を知らない. 必要なのは「車は加速・制動・操舵ができる」という用語の表面だけだ.

これは人間と車の関係にも似ている. 人間は車のエンジンの仕組みを知らなくても, 車を動かせる. 必要なのは, どう関われるかだけだ. 認識をそのまま用語にすると, その関係がコードにも現れやすい.

PlayerCar の内部を知らずに運転できるのは, カプセル化に近い. カプセル化に限らず, OOPの原則 ―― 抽象化, ポリモーフィズム, 継承 ―― はどれも, 私の解釈では 手段 であって 本質 ではない. 本質は, 認識に用語を与え, その定義を書くこと. これらの原則は, 用語と定義をうまく扱うための道具だ. 本質だと誤認すると, 原則を満たすこと自体が目的になりやすい. 手段だと捉えれば, 「適切な用語が立ち, その定義が過不足ないか」が判断基準になる.

判断基準が用語と定義にあるなら, OOPの文脈でよく使われる 「責務」 もこの枠組みで捉え直せる. 「このクラスの責務は何か」とは, 「この用語の定義として何を含めるべきか」ということだ. 単一責任原則(SRP)が「クラスの変更理由は一つであるべき」と言うのは, 用語とその定義は一つの認識のためにまとまっているべきだ, と言い換えられる.

もう一つ例を見てみよう.

// Common definition of a payment method: something that can pay
interface PaymentMethod {
    PaymentResult pay(Money amount);
}

class CreditCard implements PaymentMethod {
    public PaymentResult pay(Money amount) { ... }
}

class BankTransfer implements PaymentMethod {
    public PaymentResult pay(Money amount) { ... }
}

PaymentMethod というインターフェースが, CreditCardBankTransfer という異なる用語に pay() という共通の定義を強制している. だからどちらも同じ「支払い手段」として扱える. ここでもOOPの原則は, 用語と定義をうまく整えるための手段として働いている.

OOPの書き方は, 用語と定義の対応を自然に表しやすい

もう一つ, OOPが用語と定義を書くのに向いている理由がある. その書き方だ.

// OOP-style: the term (object) acts by itself
order.confirm(paymentInfo);

// Procedural-style: the term (data) and the operation are separated
confirm(order, paymentInfo);

order.confirm() は, 「注文は確定できるものである」という見立てをそのまま書いている. 主語と動詞の関係がコードに反映されている.

一方, confirm(order) が悪いわけではない. こちらは別の切り方をしているだけだ. ただ, OOPでは振る舞いが型に帰属する. つまり「確定する」が Order の定義の一部になるため, 用語の定義に責務の所在まで含まれる. 関数型では振る舞いは型の外にあるため, 用語と責務の帰属が分離する. 人間の見立てを, 責務ごと主語に固定したいとき, OOPの書き方は自然に見えやすい, というのが私の感覚だ.

用語の切り口が悪いとどうなるか

OOPは認識を用語にする力がある. では, 目的のためではなく, 技術的都合のみの認識で用語を切るとどうなるか.

setterという切り口の問題

setter文化は, そのズレがもっとも見えやすい例だ.

order.confirm();

この形では, order は「確定できる注文」という用語として読める. 主語と動詞が結びついていて, 何が起きているかだけでなく, それが何として扱われているかも見える.

では, confirm() という用語が存在しない世界ではどうなるか. たとえばサービスクラスの中に, こんなコードが現れる.

// A process buried somewhere in OrderService.java
order.setStatus(CONFIRMED);
order.setConfirmedAt(now);
payment.setAuthorized(true);

ここにあるのは, たしかに処理ではある. しかしそれはもはや「注文を確定する」という認識の単位ではなく, status を変える, 時刻を書く, フラグを立てる, という細かな技術的操作に分解されている.

このとき失われるのは, 単なる見た目の良さではない. 失われるのは, 用語 だ. メソッドレイヤーの用語(Order.confirm)が消えて, その定義の断片(setStatus, setConfirmedAt)だけが残っている.

本来「注文を確定する」というひとつの用語で括られていたものが, コードの上では定義の断片にほどかれてしまう. すると読み手は, そのコードを読むたびに以下のようなことを頭の中で再構成しなければならなくなる.

つまり, 用語があればコードが読者に提供してくれるはずだった認識の単位が, 用語ではなく定義だけに分解されたことでコードの表面から消えてしまう. setter / getter 中心の設計で起きがちなのは, まさにこの用語の喪失だと思っている.

同じことは, もっと小さい単位でも起きる.

// Set an email address
user.setEmail("[email protected]");

// Change an email address
user.changeEmail("[email protected]");

User.setEmail はデータ構造の都合で切った用語であり, その定義は「フィールドに値を入れる」でしかない. 一方 User.changeEmail は「メールアドレスを変更する」という行為を用語にしている. もしその定義の中に, 変更前のメールアドレスへの通知, 変更履歴の記録, バリデーション, 再認証といったルールを入れる場合は用語と定義の対応が自然となり, 閉じ込めることができる.

用語の切り口を歪ませた要因

では, なぜこういう切り方が広がったのか.

1. 永続化の都合がそのままモデルに流れ込みやすかったから. ORMは「テーブルの列 = クラスのプロパティ」という対応を強く意識させる. その結果, 用語はテーブル名の写像になり, 定義はカラムの写像になる. 認識ではなく永続化が用語と定義を決めてしまう.

2. JavaBeans規約やフレームワークが, プロパティ中心の設計を自然にしたから. getter/setter を用意しておけばフレームワークが読んでくれる. IDEが自動生成してくれる. この便利さは大きい. ただそのぶん, 「どんな用語を立てるか」「その定義は何か」を考える前に, まずプロパティの集合としてクラスを作る癖がつきやすい.

3. 分業によって意味が分断されやすかったから. 画面, API, DB の都合をそれぞれ別の構造に閉じ込めるのは実務上よくある. ただ, それぞれが自分の都合で Order を切り始めると, ドメインとしての「注文とは何か」が誰の責務でもなくなりやすい.

要するに, setter中心の設計が広がったのは, それが本質的に優れていたからというより, 技術的都合と開発体験の都合にうまく乗ったから だと思う.

行き着く先: 貧血ドメインモデル

setter中心の切り方が進むと, 用語の定義が「何ができるか」ではなく「何を持っているか」だけになる. Order という用語は残るが, その定義は statusconfirmedAt を持つだけの箱になり, 「確定する」という意味のある行為は外の OrderService に追い出される.

// Order only holds data
class Order {
    private OrderStatus status;
    private LocalDateTime confirmedAt;
    // setters/getters only...
}

// Behavior is buried in a service procedure
class OrderProcessingService {
    public void process(Order order, Payment payment) {
        order.setStatus(CONFIRMED);
        order.setConfirmedAt(now);
        payment.setAuthorized(true);
        notificationService.send(order.getEmail(), "ご注文が確定しました");
        // ...other steps continue
    }
}

これが Martin Fowler が名付けた 貧血ドメインモデル(Anemic Domain Model) と呼ばれる状態だ. 用語はあるが, 定義がスカスカ. 私はこの「貧血」という比喩を, 定義(=振る舞い)が抜かれて空っぽになった用語, と解釈している.

「注文を確定する」という行為は, process という大きな手続きの中に溶けてしまい, どこからどこまでが「確定」の定義なのかが読み取りにくくなる. コードには操作は残る. でもそれを括る用語が残らない. 何をしているかは追えても, それが何であるか が見えにくくなる.

では逆に, サービスに散らばったロジックを全部 Order クラスに詰め込めば解決するかというと, そうでもない.

class Order {
    public void confirm() { ... }
    public void cancel() { ... }
    public void sendConfirmationEmail() { ... }
    public void calculateTax() { ... }
    public void generateInvoicePdf() { ... }
    public void syncToExternalApi() { ... }
}

これは貧血の逆で, 肥大化だ. Order という一つの用語の定義に, 税計算もPDF生成も外部連携も詰め込まれている. メソッドをクラスに移しただけで, 定義が膨れすぎて用語の意味がぼやけてしまう.

大事なのは, メソッドがどこにあるかではない. 認識の単位に沿って用語を立て, 各用語に適切な定義を与えること だ. 貧血モデルなら, 「確定する」という行為を Order の定義に戻す. 肥大化したモデルなら, 税計算は TaxCalculation, PDF生成は InvoiceGenerator として, それぞれ独立した用語に切り出す. どちらも, 認識の単位と用語の単位を一致させるという同じ原則に従っている.

もちろん, DTO・ViewModel・永続化用のEntityなど, データの運搬が目的の構造ではsetterが自然な場面もある. どこでどんな切り口を使うか, それがいちばん大事だ.

DDDは用語の「向き」を定めた

OOPに足りなかったもの

OOPは「用語を立て, 定義を与える力」を持つ強力なパラダイムだ. しかし, 「その用語は誰のためか? ドメインか? DBか? フレームワークか?」を区別する仕組みはOOP自体にはない. 力はあるが, その向きは自動では決まらない. だからこそ歪みうる.

力と向き

OOPとDDDの関係を, 私はこう理解している.

OOPは用語と定義を書く「力」を与え, DDDは「どんな用語を, 誰のために立てるべきか」という問いを与えた.

DDDは, その力の向き先を ドメイン(業務領域) に定めた. 「ユビキタス言語」という考え方は, まさにチームが共有する用語を立て, その定義をコードに反映させるプラクティスだ.

OOP:  用語と定義を書く「力」
       ↓ (向きが定まらないと歪みうる)
DDD:  用語の向きを「ドメイン」に定める
結果: ドメインの認識が, 適切な用語と定義としてコードに固定される

私がDDDに惹かれた理由もここにある.

「用語とその定義を記すこと」とDDDの関係

ここで「『プログラミングは用語とその定義を記すこと』って, DDDのユビキタス言語と同じことを言っているのでは?」と思った人もいるかもしれない. 確かに重なる部分はある. ただ, DDDは「ドメイン専門家とコードが同じ言葉を使うべき」という 実践的方法論 だ. 私がここで言いたいのはもう一段手前で, プログラミングという行為そのものが用語とその定義を記す行為でもある という認識の話だ. DDDはその認識の上に, 「では誰の用語を採用すべきか」「文脈(Bounded Context)ごとに用語と定義をどう切るか」という実践的な回答を与えたもの, と私は位置づけている.

それでも残る難しさ

ただし, DDDがあっても認識を用語にする際の境界を引く難しさは消えない. ユビキタス言語は「ドメイン専門家の言葉に寄せろ」と言い, 境界を引くのを助けてくれる. しかし, ドメイン専門家の認識をどう解釈し, どの粒度で用語に切り, どこまでを一つの定義に収めるかは, 結局開発者の判断だ. OOPは用語と定義を書く力をくれる. DDDはその向きをドメインに定めてくれる. しかし, どこに境界を引くかという判断そのものは, どこまでいっても人間の認識に依存する. 同じ業務を見ても, 人によって違う Order が生まれうる. ここが設計の難しいところであり, 同時に, チームで用語と定義を揃える必要がある理由でもある.

チームと用語

ここまでは, 主に一人の開発者が用語と定義をどう書くかという話だった. しかし, チーム開発ではもう一つの問題が生まれる. 個人が用語と定義を書けることと, チーム全体でそれを共有できることは別の話だ. 一人ひとりがOOPやDDDを理解していても, それぞれの認識で用語を切り出せば, 同じコードベースの中で用語も定義もズレていく. これは「用語と定義の4つの力」で挙げた 共有の基盤 が崩れている状態だ. 用語が共有されていなければ, チームで同じ概念を指して会話することすらできない.

良い設計はチームによって異なる. あるチームにとって最適な用語体系が, 別のチームでも最適とは限らない. しかし, 一つだけ言えることがある.

業界で一般的に使われている語彙に寄せること は, チームの持続性への投資だ.

独自の用語体系は, そのチームの文脈を深く理解した人にとっては効率的かもしれない. でも, 新しいメンバーが入ったとき, メンバーが抜けたとき, その用語体系が属人的であるほどチームは脆くなる. 業界の共通語彙をベースにすることで, チームの入れ替わりに対する耐性が上がる.

もう少し踏み込むと, これは可読性の話でもある. 可読性とは, 単に読みやすい名前が付いていることではない. 用語から期待される意味と, その定義が実際に担っている意味が, 無理なく一致している状態 のことだと思っている.

まとめ

以上が, 私的OOP解釈だ.

プログラミングとは, 少なくとも設計の観点では, 用語を立てて定義を与えることだ と私は思っている.

OOPは, 認識を用語とその定義として書きやすい表現形式だ. 人間の見立てをオブジェクトという用語にし, メソッドやプロパティでその定義を与える.

DDDは, 用語の向きをドメインに定める考え方だ. OOPの力を, どこに向けるべきかを教えてくれる.

私がプログラミングに惹かれる理由は前の記事で書いた. 今回はその先, 「何を表現しているのか」を掘った形になる.

用語を丁寧に立て, その定義で世界を記述する. それだけだ.

ではまた.


あとがき ― この解釈はAI時代にどうなるのか

正直に言うと, AIを日常的に使うようになってから, 「この見方は古くなるかもしれない」と何度も思った. 今のAIは強い. コードの設計も, 命名も, かなりのレベルでやってくれる. 人間が「認識に用語を与え, 定義を書く」と丁寧に考えなくても, AIは確率的にそれらしい用語と定義を導出できてしまう.

一方で, AIに指示を出すとき, 曖昧な言葉で伝えれば曖昧な結果が返ってくる. 「いい感じにして」では「いい感じ」のコードは出てこない. あるいは, AIが生成したコードがsetterだらけの貧血モデルだったとき, 「これはおかしい」と気づけるかどうか. そこにはまだ, この記事で書いたような視点が要るのかもしれない.

ただ, この記事が示したのは一つの見方にすぎない. AIが確率で用語を導出できるなら, 人間の認識ベースのこの解釈よりも優れた観点を, AI自身が生み出す可能性だってある. 5年後にこの記事を読み返したとき, 「まだ使える見方だったな」と思うのか, 「もっといい捉え方が見つかったな」と思うのか. 今の私にはわからない. だからこそ, 今の考えをここに残しておく.

Loading comments...

Top