【入門編】Dartのfinal変数とsetterの不在:不変性を強制するクラス設計の基本 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

こんにちは!Dartの世界へようこそ。
Flutterを使ったアプリ開発や、Dart単体でのバックエンド開発、楽しんでいますか?

他のプログラミング言語(例えばJavaやC#、JavaScriptなど)からDartの世界に入ってきたとき、ふとこんな疑問を持ったことはありませんか?

「あれ? Dartのクラスって、なんで `getter` はあるのに、変更用の `setter` を安易に作っちゃいけないんだろう?」
「`final` ってつけておけば安全って聞くけど、内部の挙動はどうなっているの?」

今回は、Dartのオブジェクト指向の根幹をなす `final` 変数と「不変性(Immutability)」を強制するクラス設計の基本 について、少しだけ深掘りしてお話ししていきますね。

ここをしっかりとクリアできれば、あなたの書くコードの堅牢性は劇的に跳ね上がります。一緒にバッチリマスターしていきましょう!

—

1. なぜ「変更できないこと(不変性)」がそんなに重要なのか?

アプリが大きくなったり、複数人での開発チームで作業したりするようになると、こんなバグに頭を悩まされた経験はありませんか?

> 「あれ? 画面Aで持っていたユーザーデータの名前が、いつの間にか画面Bの処理で書き換わっちゃってる……! どこで誰が書き換えたんだ!?」

変数をいつでも、どこからでも自由に変更できるようにしている(これを「可変=Mutable」と呼びます)と、プログラムの状態変化を追うのが一気に難しくなります。

そこでDartでは、「オブジェクトを作ったあとに、その中身を勝手に書き換えられないようにする(=不変=Immutable)」 という設計を非常に強く推奨しています。そのための最大の武器が、今回主役となる `final` キーワードです。

—

2. `final` フィールドとコンストラクタの基本形

まずは、外部から中身を変更されない、堅牢なクラスの基本形を見てみましょう。

class User {
// すべてのフィールドに final を付与し、インスタンス生成後の書き換えを禁止する
final String id;
final String name;
final int age;

// コンストラクタで初期値を注入する(一度入れたら、もう二度と変更できない)
const User({
required this.id,
required this.name,
required this.age,
});
}

void main() {
// インスタンスの生成
const user = User(id: ‘A-001’, name: ‘Alice’, age: 25);

print(‘ユーザー名: ${user.name}’); // 出力: ユーザー名: Alice

// 【注目】ここで値を書き換えようとすると…?
// user.name = ‘Bob’; // 🛑 コンパイルエラー!
}

コードの裏側で何が起きているか?

`final` がついた変数は、「一度だけ値を代入でき、その後は二度と再代入(変更)できない」 という制約をDartのコンパイラに強制します。
もし誰かがうっかり `user.name = ‘Bob’` なんてコードを書こうものなら、Dartは実行するまでもなく、コンパイルの瞬間にエラーを出して止めてくれます。これが、バグを未然に防ぐ最高のセーフティネットになるんです。

—

3. 「setter」を安易に作らないという思想

他のオブジェクト指向言語では、フィールドを `private` に隠蔽して、値を書き換えるための `setXxx()` やセッターメソッドを生やすのが「お作法」とされていることも多いですよね。

ですが、Dartのモダンな設計思想において、不必要なセッターを作ることは「安全な状態を自ら壊す行為」 とみなされます。

もしセッターを作ってしまうと、以下のような状態が生まれます。

// ⚠️ 避けるべき設計の例(セッターを持たせた可変クラス)
class BadUser {
String name; // finalではない

BadUser(this.name);

// 値を書き換えるためのセッター(あるいはメソッド)
set name(String newName) {
if (newName.isNotEmpty) {
_name = newName; // 内部で書き換え可能になってしまう
}
}
}

これをしてしまうと、「この `BadUser` インスタンスは、アプリのどこかの処理で名前が変わっているかもしれない」という不安を常に抱えながらコードを読むことになります。

「じゃあ、値を変更したいときはどうするの?」

「名前が変わったユーザー」を扱いたいときは、元のオブジェクトの値を書き換えるのではなく、「新しい値を持った、別の新しいオブジェクトを新しく生み出す」 というアプローチを取ります。

Dartのクラスには、これをエレガントに行うための `copyWith` というイディオム(慣用句)がよく使われます。

class ImmutableUser {
final String id;
final String name;
final int age;

const ImmutableUser({
required this.id,
required this.name,
required this.age,
});

// 値の一部を変更した「新しいインスタンス」を返すメソッド
ImmutableUser copyWith({
String? name,
int? age,
}) {
return ImmutableUser(
id: this.id, // idは変えない
name: name ?? this.name, // 引数が渡されなければ、元の値を維持
age: age ?? this.age,
);
}
}

void main() {
var user1 = const ImmutableUser(id: ‘001’, name: ‘Charlie’, age: 30);
print(user1.name); // Charlie

// 名前だけを変えた「新しいユーザー」を作る!
var user2 = user1.copyWith(name: ‘David’);

print(user1.name); // Charlie (元のuser1は一切汚染されていない!)
print(user2.name); // David (新しく生まれたuser2)
}

この「データを破壊せず、常に新しいデータを返す(Immutable / Functional なアプローチ)」は、FlutterのUI状態管理(ProviderやRiverbloc、Blocなど)とも非常に相性が良く、画面の再描画バグを防ぐための強力な基盤となります。

—

4. 初学者が陥りがち! `final` と `const` の違い

ここで、よくある混乱ポイントをスッキリ整理しておきましょう。似ているようで、Dartにおけるこの2つは役割が全く違います。

  • `final`: 「実行時」に一度だけ値を代入できる(代入後は変更不可)。
  • 例:現在の時刻を入れる、ユーザーからの入力値をセットするなど、プログラムが動いてから決まる値に使う。
  • const: 「コンパイル時(コードを書いている時点)」に完全に値が確定している。
  • 例:数学の円周率 `3.14159` や、固定の文字列など。

void main() {
final now = DateTime.now(); // OK: 実行時の時刻が入る
// const today = DateTime.now(); // 🛑 コンパイルエラー! 「今何時かなんて、コードを書く時には分からないよ!」とDartに怒られます。
}

クラスのフィールドに `final` を使うときは、「このオブジェクトが存在している間、この値は絶対にブレない」 という約束をコンパイラと交わしているとイメージしてください。

—

まとめ:堅牢なクラス設計のチェックリスト

ここまでの内容をギュッとまとめます。明日からのコーディングで、ぜひ以下のポイントを意識してみてくださいね。

1. クラスのフィールドは、基本すべて `final` にできないか検討する。
2. 外部から書き換えさせるための「セッター」や「変更メソッド」を安易に作らない。
3. 値を変更したいときは、破壊的な変更ではなく `copyWith` パターンを使って新しいインスタンスを生成する。
4. `final`(実行時の不変)と `const`(コンパイル時の完全な定数)を適切に使い分ける。

この基本をマスターするだけで、あなたの書くDartコードの信頼性は一段も二段もレベルアップします。「意図しないデータの書き換え」という魔物から解放された、スッキリとしたコードが書けるようになりますよ。

それでは、次回の記事でもDartの深い魅力と知見を一緒にお届けしていきます。お楽しみに!