「生焼けオブジェクト」ってなに?
はじめに
リファクタリング技術を学んでいる際に表題のアンチパターンを知ったので、
今回はこれを記事にしてみたいと思います。
生焼けオブジェクトとは
これはインスタンスのプロパティが未初期化の状態でインスタンス生成がされてしまっているような状態のことを指します。
例えば次のようなJavaコードがそれが発生し得る状態です。
public class ContractAmount {
public int amountIncludingTax;
public BigDecimal salesTaxRate;
}
ContractAmount amount = new ContractAmount();
System.out.println(amount.salesTaxRate.toString());
これはsalesTaxRateプロパティの初期化を行なっていないため、
このままこのプログラムを実行するとNullPointerExceptionが発生してしまいます。
このコードは実装上、インスタンス生成時に必ず初期化をしないとバグる構成になっており、
利用者はそれを知らないと駄目な状態になっています。
このようにクラスとして不完全な状態にあるものを生焼けオブジェクトと呼ばれています。
Dartではどうか
上記はJavaの例でしたがDartではどうでしょうか。
考えてみましょう。
生焼けオブジェクト対策
Dartでは、
- コンストラクタでの完全な初期化
- Null完全性
- finalの効能
- ファクトリコンストラクタでのバリデーション
こういった仕組みにより生焼け状態を防いで完全で堅牢な状態を作ることができています。
コンストラクタでの完全な初期化
Dartではクラスのプロパティをコンストラクタの引数として受け取り、
初期化子リストやthis.構文を使って直接初期化するのが最も一般的で安全な方法です。
またプロパティにrequiredキーワードを付与することで初期化の強制を行うことができ、
コンストラクタの引数を名前付き引数にすることで明示性を生み出すこともできて、
オブジェクト生成時に必要なデータが必ず揃っていることを保証することができます。
例えば以下のコードがそうです。
class User {
final String name; // 非Nullかつ変更不可
final int age; // 非Nullかつ変更不可
// コンストラクタで全ての非Nullプロパティを初期化
// required キーワードは、呼び出し元に引数の指定を強制する
// 名前付き引数にして明示性をうむ
User({
required this.name,
required this.age,
});
}
void main() {
// 必須引数が不足しているため、コンパイルエラーが発生する
// var user1 = User(name: "Alice");
// 全ての必須引数を渡すと、完全に初期化されたオブジェクトが生成される
var user2 = User(name: "Bob", age: 30);
print("${user2.name} is ${user2.age}"); // Bob is 30
}
Null安全性
DartのNull Safety機能により、型定義時に?を付けない限り、
その変数やプロパティはnullを許容しなくなります。
この言語仕様により、「初期化されていない(つまりnullである)可能性がある」という状態をコンパイル時に検知できます。
例えば以下のコードがそうです。
class Product {
String name; // 非Null型
double? price; // NUll許容型 (nullを許す)
Product(this.name, this.price);
void display() {
// nameは非Nullなので、nullチェックなしで安全に使える
print("Product Name: $name");
// priceはNUll許容型なので、使う前にnullチェックが必要(あるいは ?., !. などを使う)
if (price != null) {
print("Price: ${price!.toStringAsFixed(2)}");
} else {
print("Price: Not available");
}
}
}
// この設計により、「名前がない」という生焼け状態はコンパイル時に防がれる。
// 価格は任意情報として扱える。
finalの効能
プロパティをfinalで定義すると、その値は一度設定されたら変更できなくなります。
これにより、オブジェクトが生成された後の「意図しない状態変化」を防ぎ、
常に一定の完全な状態を保つことができます。
例えば以下のコードがそうです。
class Configuration {
final String apiEndpoint; // 一度設定したら変更不可
final int timeoutSeconds; // 一度設定したら変更不可
Configuration({required this.apiEndpoint, this.timeoutSeconds = 30});
// setterを持たないため、外部から状態を変更できない
}
void main() {
var config = Configuration(apiEndpoint: "https://api.example.com");
// エラー: 'apiEndpoint' は final なので代入できない
// config.apiEndpoint = "https://api.new-endpoint.com";
// オブジェクトは生成時から常に有効で完全な状態を保つ
print("API Endpoint: ${config.apiEndpoint}");
}
ファクトリコンストラクタでのバリデーション
複雑な生成ロジックや入力値の検証が必要な場合、factoryキーワードを使ったファクトリコンストラクタが役立ちます。
ファクトリコンストラクタはインスタンスを直接生成するのではなく、インスタンスを返すメソッドのように機能するため、生成前に引数の正当性をチェックし、
無効な場合はエラーをスローしたり、別のデフォルトオブジェクトを返したりできます。
例えば以下のコードがそうであり、このコードでは通常のコンストラクタの方をプライベートなものとして定義をすることで、
インスタンス生成の方法自体に縛りを持たせています。
class PositiveNumber {
final int value;
// 通常のコンストラクタをプライベートにする
PositiveNumber._internal(this.value);
// ファクトリコンストラクタで入力値を検証する
factory PositiveNumber(int value) {
if (value <= 0) {
// 無効な値であればエラーをスローし、不完全なオブジェクトの生成を防ぐ
throw ArgumentError("Value must be a positive integer.");
}
// 検証を通過した値でのみ、プライベートコンストラクタを呼び出す
return PositiveNumber._internal(value);
}
}
void main() {
try {
// 検証を通過する
var num1 = PositiveNumber(10);
print("Valid number: ${num1.value}"); // Valid number: 10
// 検証でエラーが発生し、オブジェクトは生成されない
var num2 = PositiveNumber(-5);
} catch (e) {
print("Error creating object: $e"); // Error creating object: Invalid argument(s): Value must be a positive integer.
}
}
Dartでも発生し得るパターン
上記のような仕組みによりDartでは生焼けオブジェクトを作りづらくしていますが、
-
lateキーワードの使い方 - ファクトリコンストラクタや静的メソッドの使い方
こういった時は生焼け状態が発生し得ます。
lateキーワードの使い方
lateキーワードはインスタンスの生成に多大なリソースを割いてしまい、
それによるアプリケーションのクラッシュを防ぐために使用されたりします。
自動テストコードでもよく見かけたりしますね。
ただ使い方次第ではオブジェクトを生焼け状態にしてしまいます。
例えば、
class User {
late String name;
late int age;
// このコンストラクタでは初期化しない
User();
void initialize(String name, int age) {
this.name = name;
this.age = age;
}
}
void main() {
var user = User(); // この時点では「生焼け」状態
// user.name; // 初期化前にアクセスすると実行時エラー (LateInitializationError)
user.initialize("Alice", 30);
print(user.name); // OK
}
このような不要と思われる遅延初期化をしてしまうとコンパイラはエラーを吐かずとも、
プログラムの実行時にエラーを吐くだなんてことが起こったりします。
なのでlateキーワードが使い所が重要です。
ファクトリコンストラクタや静的メソッドの使い方
オブジェクトを生成する過程が複雑になると、
開発者が意図しない「初期化漏れ」や「無効な状態」のオブジェクトが作成されてしまうリスクが高まってしまいます。
例えばこんなユースケースを考えてみましょう。
ユースケース:
設定ファイルを読み込んでオブジェクトを生成するクラスを考える。
ファクトリコンストラクタを使って、読み込み元の形式(JSONかYAMLかなど)によって処理を分岐させたいとする。
ここで注意深く設計しないと、特定の場合にのみ必要なプロパティの初期化が漏れる可能性があります。
それは例えば、
class Configuration {
String databaseUrl;
String? apiKey; // APIキーはオプショナル(NUll許容型)とする
// プライベートコンストラクタで、外部からの直接生成を防ぐ
Configuration._internal(this.databaseUrl, {this.apiKey});
// ファクトリコンストラクタで生成ロジックを集中させる
factory Configuration.fromJson(Map<String, dynamic> json) {
// データベースURLは必須だが、JSON内に存在しない可能性がある
if (!json.containsKey('databaseUrl')) {
// 本来ならエラーをスローすべきだが、ここでは「不完全な状態」で続行するロジックを想定
print("Warning: databaseUrl is missing! Creating potentially incomplete object.");
// ここで仮の値(あるいはnullを許容する設計ミス)でインスタンスを生成してしまう
// この時点では apiKey も初期化されていない
return Configuration._internal("default_url");
}
// データベースURLは取得できたが、APIキーがない場合もある
final dbUrl = json['databaseUrl'] as String;
final key = json['apiKey'] as String?;
return Configuration._internal(dbUrl, apiKey: key);
}
}
void main() {
// 意図的に databaseUrl を含まない不正なJSONデータ
final Map<String, dynamic> invalidJson = {
'apiKey': 'secret123'
// 'databaseUrl' がない!
};
final config = Configuration.fromJson(invalidJson);
// 警告は出るが、実行は停止しない
print("Config URL: ${config.databaseUrl}"); // default_url が表示される
print("Config Key: ${config.apiKey}"); // secret123 が表示される
// この config オブジェクトは「生焼け」である。
// 本来期待される databaseUrl が設定されていないため、後続のDB接続処理などでエラーになる可能性がある。
}
こんな状態のことです。。。
上記のようなユースケースであれば本来はdatabaseUrlとapiKeyは本来必須情報なはずです。
ですが上記のような誤った型定義をしてしまった上で分岐ロジックを複雑化させてしまい、
例外処理時のロジックをミスしてしまうと想定外の生焼け状態を生み出してしまいます。。。
解消法
このようなことを防ぐためにも、ファクトリコンストラクタなどでは以下のような
バリデーションと適切な例外処理を施したよりクリーンな実装が求められます。
class Configuration {
final String databaseUrl; // finalと非Nullで堅牢に
final String? apiKey;
Configuration._internal(this.databaseUrl, {this.apiKey});
factory Configuration.fromJson(Map<String, dynamic> json) {
if (!json.containsKey('databaseUrl')) {
// 必須情報がなければ、ArgumentError をスローしてオブジェクト生成を中止する
throw ArgumentError("Missing required parameter: 'databaseUrl'");
}
final dbUrl = json['databaseUrl'] as String;
final key = json['apiKey'] as String?;
// エラーなくここまで来たら、完全なオブジェクトを生成できる
return Configuration._internal(dbUrl, apiKey: key);
}
}
void main() {
final Map<String, dynamic> invalidJson = {
'apiKey': 'secret123'
};
try {
// 生成時にエラーがスローされるため、config変数は生成されない
final config = Configuration.fromJson(invalidJson);
print("Config URL: ${config.databaseUrl}");
} catch (e) {
// 例外をキャッチし、不正な状態のオブジェクトが使われることを防ぐ
print("Failed to create configuration object: $e");
}
}
// 出力: Failed to create configuration object: Invalid argument(s): Missing required parameter: 'databaseUrl'
まとめ
Dartにはオブジェクトの生焼け化を防ぐための仕組みは提供されています。
ただし使い方次第では抜け穴が生まれてしまうため、細心の注意を払わないといけないでしょう。
参考
Discussion