Java | オブジェクト指向:不変条件の守り方

Java Java
スポンサーリンク

不変条件ってそもそも何なのか

不変条件(invariant)は
「このオブジェクトが“生きている間ずっと”守られていないといけない約束」
のことです。

例えると、

ユーザーのポイントは常に 0 以上
注文は明細を 1 件以上必ず持つ
メールアドレスは常に正しい形式
口座残高は常に 0 以上

みたいな、「このクラスが“まともな状態”であるための前提」です。

オブジェクト指向では、
「その不変条件を“コードで”どう守るか」がめちゃくちゃ重要になります。
ここをちゃんとやると、バグの入り方が目に見えて変わります。


不変条件を守れないコードの典型例

何でもアリにしてしまう User クラス

まずは「不変条件ガバガバ」な例から。

final class User {

    private String name;
    private String email;
    private int point;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public int getPoint() { return point; }
    public void setPoint(int point) { this.point = point; }
}
Java

この User に対して、呼び出し側はこうできます。

User u = new User();
u.setName(null);                   // 名前が null
u.setEmail("aaa");                 // メールアドレスっぽくない
u.setPoint(-100);                  // マイナスのポイント
Java

もし業務的には

名前は必須
メールは「@」を含む形式
ポイントは常に 0 以上

というルールがあるなら、
このクラスは「不変条件をまったく守っていない」状態です。

不変条件を守れていないと何が起きるかというと、

「どこかで u の状態がおかしくなっても、その時点では例外も何も起きない」
「ずっと後になって“あれ、なんで point マイナスなんだ?”とデバッグする羽目になる」

という泥沼にハマります。


守り方 1: コンストラクタで「おかしい状態」をそもそも作らせない(重要)

生成の瞬間に「正しさ」をチェックする

もっとも強力な守り方は、
「変なオブジェクトは、そもそも new できないようにする」ことです。

先ほどの User を、不変条件を守るように書き直します。

final class User {

    private final String name;
    private final String email;
    private final int point;

    User(String name, String email, int point) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("名前は必須です");
        }
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("メール形式が不正です");
        }
        if (point < 0) {
            throw new IllegalArgumentException("ポイントは0以上です");
        }
        this.name = name;
        this.email = email;
        this.point = point;
    }

    String name() { return name; }
    String email() { return email; }
    int point() { return point; }
}
Java

このクラスは、不変条件をこう定義しています。

名前は null/空文字禁止
メールは必ず「@」を含む
ポイントは 0 以上

それをコンストラクタでチェックし、
条件を満たさないなら例外を投げて “そもそも作らせない”。

これで、「User のインスタンスは必ずこの条件を満たしている」と言い切れるようになります。

イミュータブルにするとさらに安心

上の例ではフィールドをすべて final にしました。
こうすると、作られたあとは状態を二度と変えられません。

一度でも不変条件を守った状態で作れていれば、
その後は「勝手に壊される心配がない」ということです。

イミュータブルにできない事情もありますが、
できるところは final にしてしまうと、不変条件の守りが一段強くなります。


守り方 2: 変更メソッドの中でルールをチェックする

setter ではなく「意味のある操作」にする

どうしても状態を変更する必要があるクラスもあります。
その場合でも「ただの setter」にせず、
「そのクラスにとって意味のある操作」としてメソッドを定義して、
その中できっちり不変条件を守ります。

さっきの User に「ポイントを増やす」という操作を足すとします。

final class User {

    private final String name;
    private final String email;
    private int point;

    User(String name, String email, int point) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("名前は必須");
        }
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("メール形式が不正");
        }
        if (point < 0) {
            throw new IllegalArgumentException("ポイントは0以上");
        }
        this.name = name;
        this.email = email;
        this.point = point;
    }

    void addPoint(int added) {
        if (added < 0) {
            throw new IllegalArgumentException("マイナス加算は禁止");
        }
        int result = this.point + added;
        if (result < 0) {           // オーバーフロー防御のための例
            throw new IllegalStateException("ポイントオーバーフロー");
        }
        this.point = result;
    }

    String name() { return name; }
    String email() { return email; }
    int point() { return point; }
}
Java

addPoint は、「このクラスの不変条件を壊さないようにポイントを変更する」唯一の入り口です。
呼び出し側はもう setPoint などは呼べません。

「状態を変えるメソッドの中こそ、不変条件を守るチェックを書く場所」
という意識を持っておくと、設計の軸がブレにくくなります。


守り方 3: セットで整合が必要なものは、一緒に操作する

片方だけ変えられる API は危険

例えば「注文」の例を考えます。

合計金額 totalPrice と、明細 lines があるとします。

final class Order {

    private List<OrderLine> lines;
    private int totalPrice;

    public void setLines(List<OrderLine> lines) { this.lines = lines; }
    public void setTotalPrice(int totalPrice) { this.totalPrice = totalPrice; }
}
Java

このように「明細」と「合計金額」をバラバラに変更できるようにすると、
簡単に矛盾した状態(明細と totalPrice が合ってない)が作れてしまいます。

不変条件を守るなら、
「明細の変更と合計金額の変更はセットで行うメソッドにする」のが筋がいいです。

final class Order {

    private final List<OrderLine> lines = new ArrayList<>();

    void addLine(Item item, int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("数量は1以上");
        }
        this.lines.add(new OrderLine(item, quantity));
        // 合計金額は totalPrice() メソッドで毎回計算する、などにする
    }

    Money totalPrice() {
        Money total = Money.zero();
        for (OrderLine line : lines) {
            total = total.add(line.lineTotal());
        }
        return total;
    }
}
Java

「合計金額は保持せず、その都度明細から計算」
あるいは
「内部でだけ totalPrice を更新し、外から直接書き換えられないようにする」

といった工夫で、「矛盾した状態をそもそも作れない API」にします。

不変条件を守るうえで、

このフィールドとこのフィールドは、セットで一貫性がある必要があるか?
片方だけ変更できるメソッドを提供してしまっていないか?

という自問はかなり効きます。


守り方 4: 値オブジェクトに小さな不変条件を閉じ込める(重要)

int や String のままだと守りにくい

例えば「金額」「メールアドレス」「個数」などを、
そのまま intString で持っていると、不変条件が守りにくくなります。

int price;     // 0 以上? 税込? 税抜? 通貨は?
String email; // 空文字? 半角だけ? 形式チェック?
Java

こういう「意味のある値」は、小さなクラス(値オブジェクト)にしてしまうと守りやすくなります。

Money の例

final class Money {

    private final int amount;

    Money(int amount) {
        if (amount < 0) {
            throw new IllegalArgumentException("金額は0以上");
        }
        this.amount = amount;
    }

    static Money zero() {
        return new Money(0);
    }

    Money add(Money other) {
        long result = (long) this.amount + other.amount;  // オーバーフロー対策例
        if (result > Integer.MAX_VALUE) {
            throw new IllegalStateException("金額オーバーフロー");
        }
        return new Money((int) result);
    }

    int amount() {
        return amount;
    }
}
Java

「金額は 0 以上」という不変条件を Money の中に閉じ込めました。
これを使う側は、もう「0 未満かどうか」を毎回チェックしなくて済みます。

Money price = new Money(1000);
Money total = price.add(new Money(500));

new Money(-100) をしようとすれば、その場で例外になります。
「金額としてありえない状態」はシステムに入り込めません。

このように、不変条件を持った小さなクラスをたくさん作ると、
「システム全体の安全性」がじわじわ効いてきます。


守り方 5: 不変条件をテストコードで“言語化”する

「このクラスの不変条件は何か」をテストに落とす

不変条件はコードの中でチェックするだけでなく、
テストコードとして「目に見える形」にしておくと強いです。

User の例なら、こんなテストが考えられます。

void 名前が空なら例外() {
    assertThrows(IllegalArgumentException.class,
        () -> new User("", "a@example.com", 0));
}

void ポイントがマイナスなら例外() {
    assertThrows(IllegalArgumentException.class,
        () -> new User("Taro", "a@example.com", -1));
}

void addPointでマイナスを渡したら例外() {
    User u = new User("Taro", "a@example.com", 0);
    assertThrows(IllegalArgumentException.class,
        () -> u.addPoint(-10));
}
Java

これを見れば、「このクラスが守るべき不変条件」がテスト名として一目で分かります。

不変条件は「何となく暗黙の了解」で済ませると、
いつの間にか誰かが破り始めます。

テストという形で「このクラスはこうあるべき」と明文化しておくと、
後から仕様を変えるときも安心して動けます。


まとめ:不変条件を守る設計かどうかをチェックする視点

不変条件の守り方は、要するにこういうことです。

作る瞬間(コンストラクタ)でおかしな状態を拒否できているか
状態を変えるメソッドの中で一貫性を壊していないか
セットで整合性が必要なフィールドをバラバラに変えられていないか
int や String のまま放置している「意味のある値」はないか
テストで「このクラスのルール」を表現できているか

どれか一つでも意識し始めると、設計の感覚がかなり変わります。

タイトルとURLをコピーしました