はじめに
Dart 2.12(Flutter 2.0)で導入された Null Safety(null 安全) は、Dart の型システムにおける最も重要な機能の一つです。null が原因の実行時エラーをコンパイル時に検出できるようになり、安全なコードを書くための仕組みが言語レベルで提供されています。
この記事では、Null Safety の基本概念から実践的な使い方まで、コード例を交えて解説します。
null が原因のエラーとは
Null Safety がない言語では、以下のようなエラーが実行時に発生します。
// これは Null Safety が有効な Dart ではコンパイルエラーになる
// 概念的な例として示す
void main() {
String name = null; // Null Safety 有効時はコンパイルエラー
print(name.length); // 実行時に NoSuchMethodError が発生
}
他の言語では NullPointerException(Java)や TypeError: Cannot read properties of null(JavaScript)として知られるこのエラーは、最も頻繁に遭遇するバグの一つです。Dart の Null Safety は、このようなエラーをコンパイル時点で防止します。
Non-nullable 型と Nullable 型
Null Safety が有効な Dart では、すべての型はデフォルトで non-nullable(null を許容しない) です。
void main() {
// Non-nullable 型: null を代入できない
String name = 'Taro';
int age = 25;
double height = 170.5;
bool isStudent = true;
// name = null; // コンパイルエラー: A value of type 'Null' can't be assigned to a variable of type 'String'.
// Nullable 型: 型の後ろに ? を付けると null を許容する
String? nickname = null;
int? score = null;
print(name); // Taro
print(nickname); // null
print(score); // null
// Nullable 型に値を代入することもできる
nickname = 'Ta-kun';
score = 95;
print(nickname); // Ta-kun
print(score); // 95
}
出力:
Taro
null
null
Ta-kun
95
型の対応を整理すると以下の通りです。
| Non-nullable 型 | Nullable 型 | null 代入 |
|---|---|---|
String |
String? |
不可 / 可 |
int |
int? |
不可 / 可 |
List<int> |
List<int>? |
不可 / 可 |
null チェックの方法
if 文による null チェック
void main() {
String? message = 'Hello';
if (message != null) {
// この中では message は String として扱える(型プロモーション)
print(message.length); // 5
}
String? empty;
if (empty == null) {
print('empty は null です'); // empty は null です
}
}
出力:
5
empty は null です
null assertion operator !
! 演算子は、Nullable 型の値が null でないことをプログラマが保証する場合に使います。もし値が null だった場合、実行時に例外がスローされます。
void main() {
String? name = 'Taro';
// ! で Non-nullable 型に変換
String nonNullName = name!;
print(nonNullName); // Taro
// 危険な例: null に対して ! を使うと実行時エラー
String? nullValue;
// String result = nullValue!; // 実行時エラー: Null check operator used on a null value
print('上の行をコメント解除すると実行時エラーになります');
}
出力:
Taro
上の行をコメント解除すると実行時エラーになります
注意:
!の多用は Null Safety の恩恵を無効にします。!を使う前に、?.や??で安全に処理できないか検討してください。
null-aware operators(null アウェア演算子)
Dart には null を安全に扱うための演算子が複数用意されています。
?.(null 条件アクセス演算子)
レシーバが null の場合、プロパティアクセスやメソッド呼び出しを行わず null を返します。
void main() {
String? name = 'Flutter';
String? nullName;
print(name?.length); // 7
print(nullName?.length); // null
// メソッド呼び出しにも使える
print(name?.toUpperCase()); // FLUTTER
print(nullName?.toUpperCase()); // null
}
出力:
7
null
FLUTTER
null
??(null 合体演算子)
左辺が null の場合に右辺の値を返します。
void main() {
String? input = null;
String result = input ?? 'デフォルト値';
print(result); // デフォルト値
String? input2 = 'ユーザー入力';
String result2 = input2 ?? 'デフォルト値';
print(result2); // ユーザー入力
// チェーンも可能
String? a;
String? b;
String? c = 'C';
String value = a ?? b ?? c ?? 'none';
print(value); // C
}
出力:
デフォルト値
ユーザー入力
C
??=(null 合体代入演算子)
変数が null の場合にのみ値を代入します。
void main() {
String? name;
print(name); // null
name ??= 'Anonymous';
print(name); // Anonymous
// すでに null でないので代入されない
name ??= 'Other';
print(name); // Anonymous
}
出力:
null
Anonymous
Anonymous
?..(null アウェアカスケード演算子)
レシーバが null でない場合にのみカスケード操作を実行します。
void main() {
List<int>? numbers = [1, 2, 3];
numbers
?..add(4)
..add(5)
..sort();
print(numbers); // [1, 2, 3, 4, 5]
List<int>? nullList;
nullList
?..add(1)
..add(2);
print(nullList); // null(何も実行されない)
}
出力:
[1, 2, 3, 4, 5]
null
late キーワード(遅延初期化)
late は、変数の初期化を宣言時ではなく後から行うことを宣言するキーワードです。Flutter では initState() での初期化など、頻繁に使われます。
void main() {
// late を使うと、Non-nullable 型でも後から初期化できる
late String greeting;
// 何らかの処理の後に初期化
greeting = 'こんにちは';
print(greeting); // こんにちは
// late は遅延評価にも使える
late int expensiveValue = computeExpensiveValue();
// この時点では computeExpensiveValue() はまだ呼ばれていない
print('expensiveValue にアクセスする前');
print(expensiveValue); // ここで初めて computeExpensiveValue() が呼ばれる
}
int computeExpensiveValue() {
print('重い計算を実行中...');
return 42;
}
出力:
こんにちは
expensiveValue にアクセスする前
重い計算を実行中...
42
注意:
late変数に初期化前にアクセスするとLateInitializationErrorが発生します。初期化されることが確実な場合にのみ使用してください。
void main() {
late String value;
// print(value); // LateInitializationError: Field 'value' has not been initialized.
print('late 変数を初期化前に読み取るとエラーになります');
}
出力:
late 変数を初期化前に読み取るとエラーになります
required キーワード
名前付き引数はデフォルトでオプショナルですが、required を付けると必須引数になります。
void main() {
greet(name: 'Taro', age: 25);
// greet(name: 'Taro'); // コンパイルエラー: The named parameter 'age' is required.
}
// required を付けた名前付き引数は省略できない
void greet({required String name, required int age, String? title}) {
String displayName = title != null ? '$title $name' : name;
print('$displayName さん ($age歳) こんにちは!');
}
出力:
Taro さん (25歳) こんにちは!
required と Nullable 型の組み合わせも可能です。
void main() {
// required かつ nullable: 引数は必須だが null を渡すことは許容
registerUser(name: 'Hanako', email: null);
}
void registerUser({required String name, required String? email}) {
print('名前: $name');
print('メール: ${email ?? "未登録"}');
}
出力:
名前: Hanako
メール: 未登録
型プロモーション
null チェックを行うと、Dart コンパイラは変数の型を自動的に Non-nullable 型に昇格(プロモーション)させます。
void main() {
String? name = 'Dart';
// ここでは name は String? 型
if (name != null) {
// null チェック後、この中では name は String 型に昇格
print(name.length); // 4(String のプロパティに直接アクセスできる)
print(name.toUpperCase()); // DART
}
// is による型チェックでもプロモーションが起こる
Object value = 'Hello';
if (value is String) {
print(value.length); // 5(String 型としてアクセスできる)
}
}
出力:
4
DART
5
ただし、型プロモーションには制限があります。クラスのフィールドに対しては型プロモーションが行われません。
class UserProfile {
String? bio;
UserProfile({this.bio});
void printBio() {
// bio はクラスのフィールドなので型プロモーションされない
// if (bio != null) {
// print(bio.length); // 警告: フィールドはプロモーションされない
// }
// 解決法1: ローカル変数に代入する
final localBio = bio;
if (localBio != null) {
print(localBio.length); // OK
}
// 解決法2: ! を使う(null でないことが確実な場合)
if (bio != null) {
print(bio!.length); // OK(ただし ! の使用は慎重に)
}
}
}
void main() {
final user = UserProfile(bio: 'Dart エンジニア');
user.printBio();
}
出力:
10
10
'Dart エンジニア' の文字数は「D, a, r, t, 空白, エ, ン, ジ, ニ, ア」の 10文字 です。Dart の String.length は UTF-16 コードユニット数を返しますが、この文字列はすべて BMP(基本多言語面)の文字なので、文字数と一致します。
実践例1: ユーザープロフィール(オプショナルフィールド)
class UserProfile {
final String name;
final String email;
final int? age;
final String? bio;
final String? avatarUrl;
final List<String>? hobbies;
UserProfile({
required this.name,
required this.email,
this.age,
this.bio,
this.avatarUrl,
this.hobbies,
});
String getDisplayInfo() {
final buffer = StringBuffer();
buffer.writeln('=== ユーザープロフィール ===');
buffer.writeln('名前: $name');
buffer.writeln('メール: $email');
buffer.writeln('年齢: ${age ?? "非公開"}');
buffer.writeln('自己紹介: ${bio ?? "未設定"}');
buffer.writeln('アバター: ${avatarUrl ?? "デフォルト画像"}');
final hobbyText = hobbies?.join(', ') ?? 'なし';
buffer.writeln('趣味: $hobbyText');
return buffer.toString();
}
// null アウェア演算子を活用した例
int get bioLength => bio?.length ?? 0;
String get avatarDisplay => avatarUrl ?? 'https://example.com/default.png';
bool get hasHobbies => hobbies?.isNotEmpty ?? false;
}
void main() {
// すべてのフィールドを指定
final user1 = UserProfile(
name: '田中太郎',
email: 'taro@example.com',
age: 30,
bio: 'Flutter が好きです',
avatarUrl: 'https://example.com/taro.png',
hobbies: ['プログラミング', '読書', 'ランニング'],
);
// 最低限のフィールドのみ
final user2 = UserProfile(
name: '佐藤花子',
email: 'hanako@example.com',
);
print(user1.getDisplayInfo());
print('自己紹介の長さ: ${user1.bioLength}');
print('趣味あり: ${user1.hasHobbies}');
print('');
print(user2.getDisplayInfo());
print('自己紹介の長さ: ${user2.bioLength}');
print('趣味あり: ${user2.hasHobbies}');
}
出力:
=== ユーザープロフィール ===
名前: 田中太郎
メール: taro@example.com
年齢: 30
自己紹介: Flutter が好きです
アバター: https://example.com/taro.png
趣味: プログラミング, 読書, ランニング
自己紹介の長さ: 13
趣味あり: true
=== ユーザープロフィール ===
名前: 佐藤花子
メール: hanako@example.com
年齢: 非公開
自己紹介: 未設定
アバター: デフォルト画像
趣味: なし
自己紹介の長さ: 0
趣味あり: false
実践例2: API レスポンスの処理
API からのレスポンスには null が含まれることが多く、Null Safety の知識が不可欠です。
// API レスポンスをシミュレートするクラス
class ApiResponse {
final int statusCode;
final Map<String, dynamic>? data;
final String? errorMessage;
ApiResponse({
required this.statusCode,
this.data,
this.errorMessage,
});
}
class User {
final String name;
final String? email;
final int? age;
User({required this.name, this.email, this.age});
@override
String toString() {
return 'User(name: $name, email: ${email ?? "不明"}, age: ${age ?? "不明"})';
}
}
// API レスポンスから User を生成する関数
User? parseUser(ApiResponse response) {
if (response.statusCode != 200) {
print('エラー: ${response.errorMessage ?? "不明なエラー"}');
return null;
}
final data = response.data;
if (data == null) {
print('エラー: レスポンスデータが空です');
return null;
}
final name = data['name'] as String?;
if (name == null) {
print('エラー: 名前フィールドが見つかりません');
return null;
}
return User(
name: name,
email: data['email'] as String?,
age: data['age'] as int?,
);
}
void main() {
// 成功レスポンス(すべてのフィールドあり)
final response1 = ApiResponse(
statusCode: 200,
data: {'name': 'Taro', 'email': 'taro@example.com', 'age': 25},
);
// 成功レスポンス(一部フィールドが null)
final response2 = ApiResponse(
statusCode: 200,
data: {'name': 'Hanako'},
);
// エラーレスポンス
final response3 = ApiResponse(
statusCode: 404,
errorMessage: 'ユーザーが見つかりません',
);
// データなしレスポンス
final response4 = ApiResponse(
statusCode: 200,
);
final user1 = parseUser(response1);
print('結果1: $user1');
final user2 = parseUser(response2);
print('結果2: $user2');
final user3 = parseUser(response3);
print('結果3: $user3');
final user4 = parseUser(response4);
print('結果4: $user4');
}
出力:
結果1: User(name: Taro, email: taro@example.com, age: 25)
結果2: User(name: Hanako, email: 不明, age: 不明)
エラー: ユーザーが見つかりません
結果3: null
エラー: レスポンスデータが空です
結果4: null
練習問題
問題1: null-aware 演算子の適用
以下のコードを null-aware 演算子(?.、??、??=)を使ってリファクタリングしてください。
void main() {
String? firstName;
String? lastName = 'Yamada';
// このコードを null-aware 演算子でリファクタリングする
String displayName;
if (firstName != null) {
displayName = firstName;
} else if (lastName != null) {
displayName = lastName;
} else {
displayName = 'Guest';
}
print(displayName); // Yamada
String? city;
if (city == null) {
city = 'Tokyo';
}
print(city); // Tokyo
}
模範解答
void main() {
String? firstName;
String? lastName = 'Yamada';
// ?? を連鎖して簡潔に書く
String displayName = firstName ?? lastName ?? 'Guest';
print(displayName); // Yamada
// ??= で null の場合のみ代入
String? city;
city ??= 'Tokyo';
print(city); // Tokyo
}
出力:
Yamada
Tokyo
問題2: Nullable 型を含むクラスの実装
以下の仕様に基づいて Product クラスを実装してください。
-
name(String、必須) -
price(int、必須) -
description(String?、オプション) -
discountPercent(int?、オプション) -
getDiscountedPrice()メソッド: 割引率がある場合は割引後の価格を返し、ない場合は元の価格を返す -
getSummary()メソッド: 商品情報を文字列で返す
模範解答
class Product {
final String name;
final int price;
final String? description;
final int? discountPercent;
Product({
required this.name,
required this.price,
this.description,
this.discountPercent,
});
int getDiscountedPrice() {
final discount = discountPercent;
if (discount != null) {
return (price * (100 - discount) / 100).round();
}
return price;
}
String getSummary() {
final desc = description ?? '説明なし';
final priceInfo = discountPercent != null
? '$price円 → ${getDiscountedPrice()}円($discountPercent%OFF)'
: '$price円';
return '$name: $priceInfo / $desc';
}
}
void main() {
final product1 = Product(
name: 'Flutter本',
price: 3000,
description: 'Flutter入門書',
discountPercent: 20,
);
final product2 = Product(
name: 'ステッカー',
price: 500,
);
print(product1.getSummary());
// Flutter本: 3000円 → 2400円(20%OFF) / Flutter入門書
print(product2.getSummary());
// ステッカー: 500円 / 説明なし
}
出力:
Flutter本: 3000円 → 2400円(20%OFF) / Flutter入門書
ステッカー: 500円 / 説明なし
計算の確認: 3000 * (100 - 20) / 100 = 3000 * 80 / 100 = 2400
問題3: late と型プロモーションの理解
次のコードにはエラーがあります。エラーの原因を説明し、修正してください。
class Config {
String? apiKey;
late String baseUrl;
void initialize(String key) {
apiKey = key;
baseUrl = 'https://api.example.com';
}
void printConfig() {
print('Base URL: $baseUrl');
if (apiKey != null) {
print('API Key の長さ: ${apiKey.length}'); // エラー
}
}
}
void main() {
final config = Config();
config.initialize('my-secret-key');
config.printConfig();
}
模範解答
apiKey はクラスのフィールドのため、if (apiKey != null) で null チェックしても型プロモーションが行われません。null チェックと apiKey.length のアクセスの間に別のスレッドやメソッドから apiKey が null に変更される可能性を排除できないためです。
修正方法は主に2つあります。
class Config {
String? apiKey;
late String baseUrl;
void initialize(String key) {
apiKey = key;
baseUrl = 'https://api.example.com';
}
void printConfig() {
print('Base URL: $baseUrl');
// 修正法1: ローカル変数に代入して型プロモーションを利用
final localApiKey = apiKey;
if (localApiKey != null) {
print('API Key の長さ: ${localApiKey.length}');
}
// 修正法2: null assertion operator を使う(null でないことが確実な場合)
// if (apiKey != null) {
// print('API Key の長さ: ${apiKey!.length}');
// }
}
}
void main() {
final config = Config();
config.initialize('my-secret-key');
config.printConfig();
}
出力:
Base URL: https://api.example.com
API Key の長さ: 13
計算の確認: 'my-secret-key' は「m, y, -, s, e, c, r, e, t, -, k, e, y」で 13 文字です。
まとめ
| 機能 | 構文 | 説明 |
|---|---|---|
| Nullable 型 | String? |
null を許容する型 |
| null 条件アクセス | ?. |
null なら null を返す |
| null 合体 | ?? |
null ならデフォルト値を返す |
| null 合体代入 | ??= |
null の場合のみ代入 |
| null アウェアカスケード | ?.. |
null でない場合のみカスケード実行 |
| null assertion | ! |
Non-nullable に変換(null なら例外) |
| 遅延初期化 | late |
後から初期化する Non-nullable 変数 |
| 必須引数 | required |
名前付き引数を必須にする |
Null Safety を正しく理解し活用することで、null に起因する実行時エラーを大幅に削減できます。! の多用は避け、?. や ?? を使って安全にコードを書くことを心がけましょう。
参考
- Dart 公式ドキュメント - Sound null safety
- Dart 公式ドキュメント - Understanding null safety
- Dart 公式ドキュメント - Migrating to null safety
- Dart 言語仕様 - Null safety
- Flutter 公式ドキュメント
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!