Dart Null 安全性 — NullPointerException を撲滅する

Null 安全性は Dart の鎧である — コンパイラが null ポインタ攻撃の 95% をブロックする手助けをしてくれる。

1. 学べること


2. 開発者のリアルな物語

(1) 課題:NullPointerException は最も一般的なランタイムクラッシュ

Bob の DataPipeline はランタイムクラッシュに悩まされており、その 60% が NullPointerException だった。API が null の注文 ID を返し、CSV で任意のフィールドが欠落し、設定オプションが設定なしに使われる — どの状況もプログラムをクラッシュさせ、インシデントごとに平均 5 万件の注文に影響した。

(2) 解決策:堅牢な Null 安全性

Dart 3 の堅牢な Null 安全性は型を nullable T? と non-nullable T に分ける。コンパイラはコンパイル時に non-nullable 型が決して null にならないことを保証する。

DART
// Non-nullable: コンパイラが non-null を保証
String orderId = 'ORD-001';    // null にできない
// orderId = null;              // コンパイルエラー!

// Nullable: 使用前にチェックが必要
String? nickname;               // null になれる
int length = nickname?.length ?? 0;  // 安全なアクセス
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

(3) 効果


3. 堅牢な Null 安全性の原則

(1) 型システム

100%
flowchart TD
  A[変数宣言] --> B{null になれるか?}
  B -->|はい| C["T? Nullable 型"]
  B -->|いいえ| D["T Non-nullable 型"]
  C --> E["使用前にチェック必須"]
  E --> F["if (x != null) → T に昇格"]
  C --> G["?? デフォルト値提供"]
  C --> H["!. 強制アンラップ - 危険"]
  D --> I["直接使用 - 安全"]
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

▶ サンプル:Nullable vs Non-nullable 型

DART
void main() {
  // Non-nullable 型 - null を保持できない
  String name = 'DataPipeline';
  int count = 100;
  double amount = 1500.0;

  // name = null;    // コンパイルエラー!
  // count = null;   // コンパイルエラー!

  // Nullable 型 - null を保持できる
  String? nickname;
  int? maxRetries;
  double? discountRate;

  print(nickname);       // null
  print(maxRetries);     // null
  print(discountRate);   // null

  // Nullable 型は使用前のチェックが必要
  // print(nickname.length);  // コンパイルエラー!null の可能性
  print(nickname?.length);    // null (安全)
  print(nickname?.length ?? 0); // 0 (デフォルト付き)
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
null を保持可能 使用前のチェック
T いいえ 不要 String name = 'Bob'
T? はい 必須 String? name

4. Null チェックと型昇格

(1) 型昇格

コンパイラが nullable 変数が null でないと確認したとき、自動的に non-nullable 型に昇格する。

▶ サンプル:if チェックによる昇格

DART
void main() {
  String? name = 'Bob';

  // チェック前 - nullable
  // print(name.length);  // エラー!

  // null チェック後 - non-nullable に昇格
  if (name != null) {
    print(name.length);   // OK!name は String に昇格
    print(name.toUpperCase());  // OK!
  }
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

▶ サンプル:複数の Null チェック方法

DART
void main() {
  String? city;

  // 方法 1: if-null チェック
  if (city != null) {
    print(city.length);  // 昇格
  }

  // 方法 2: null 合体演算子 ??
  String safeCity = city ?? 'Unknown';
  print(safeCity.length);  // 常に安全

  // 方法 3: null 認識アクセス ?.
  int? length = city?.length;
  print(length);  // null

  // 方法 4: late 初期化
  late String resolvedCity;
  resolvedCity = city ?? 'Unknown';
  print(resolvedCity.length);  // 安全

  // 方法 5: デバッグモードでの assert
  assert(city != null, 'City must not be null');
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
チェック方法 構文 昇格効果 安全性
if-null if (x != null) T に昇格 最高
?? x ?? default T を返す
?. x?.method() T? を返す
! x!.method() T として扱う 低(クラッシュ可能性)

5. late キーワード

(1) 遅延初期化

▶ サンプル:late の基本的な使い方

DART
class DataPipeline {
  // late - 後で初期化されるが、使用前は保証される
  late String outputPath;
  late final DateTime startTime;

  void configure(String path) {
    outputPath = path;  // 最初の代入
  }

  void start() {
    startTime = DateTime.now();  // late final - 1 回設定
    print('Pipeline started at $startTime');
  }
}

void main() {
  final pipeline = DataPipeline();
  pipeline.configure('/tmp/reports');
  pipeline.start();

  // 初期化式付きの Late 初期化
  late final int maxRecords = _loadConfig();
  // maxRecords は初回アクセス時にのみ計算される
  print('Max records: $maxRecords');
}

int _loadConfig() {
  print('Loading config...');
  return 1000000;
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

▶ サンプル:late のリスク

DART
class RiskyPipeline {
  late String config;

  void process() {
    // config が設定されていない場合、LateInitializationError をスロー
    print(config);  // ランタイムクラッシュの可能性!
  }
}

void main() {
  final pipeline = RiskyPipeline();
  // pipeline.process();  // LateInitializationError!

  // 安全なパターン:コンストラクタで初期化
  final safePipeline = SafePipeline('/etc/config.yaml');
  safePipeline.process();  // OK
}

class SafePipeline {
  late String config;

  SafePipeline(String configPath) {
    config = _loadConfig(configPath);
  }

  void process() => print('Config: $config');

  String _loadConfig(String path) => 'Loaded from $path';
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
late の形式 初期化 代入回数 リスク
late T 使用前 複数 中(初期化前アクセスでクラッシュ)
late final T 使用前 1
late final T = expr 初回アクセス時 1(自動)

6. ! 演算子

(1) 強制アンラップ

! はコンパイラに「この値は null でないと確信している」と伝え、null チェックをスキップする。ただし値が null の場合、TypeError をスローする。

▶ サンプル:! の使用

DART
void main() {
  String? name = 'Bob';

  // 値が non-null だと知っている場合
  print(name!.length);  // 3 - name が 'Bob' なので OK

  // 危険:null の場合クラッシュ!
  String? maybeNull;
  // print(maybeNull!.length);  // ランタイム TypeError!

  // 安全な代替:?? または if-null チェックを使用
  print(maybeNull?.length ?? 0);  // 0 - 安全
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

(2) ! を使うべき場面

▶ サンプル:! の合理的なユースケース

DART
class Order {
  final String id;
  final double amount;
  Customer? customer;  // オプション関係

  Order({required this.id, required this.amount});

  // ビジネスロジックが non-null を保証するときに ! を使用
  String get customerName {
    // これは危険 - customer は null の可能性
    // return customer!.name;  // 悪い

    // より安全:デフォルト付きセーフアクセス
    return customer?.name ?? 'Unknown Customer';
  }
}

class Customer {
  final String name;
  Customer(this.name);
}

void main() {
  final order = Order(id: 'ORD-001', amount: 1500.0);
  print(order.customerName);  // Unknown Customer

  // customer 設定後
  order.customer = Customer('Alice');
  print(order.customerName);  // Alice
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
ユースケース 推奨 非推奨
nullable 変数 ?. / ?? / if-null !
API 戻り値 null チェック !
コレクションの .first .firstOrNull ?? default .first!
アサーション後 !(アサーションが non-null を保証)

7. Null 安全性とコレクション

▶ サンプル:コレクションでの Null 処理

DART
void main() {
  // nullable 要素を持つ List
  List<String?> names = ['Alice', null, 'Bob', null, 'Charlie'];

  // null をフィルタ - 型昇格が機能
  final nonNull = names.whereType<String>().toList();
  print(nonNull);  // [Alice, Bob, Charlie]

  // nullable 値を持つ Map
  Map<String, double?> revenue = {
    'Electronics': 3600.0,
    'Books': null,
    'Clothing': 890.0,
  };

  // non-null 値を持つエントリをフィルタ
  final validRevenue = Map.fromEntries(
    revenue.entries.where((e) => e.value != null),
  );
  print(validRevenue);  // {Electronics: 3600.0, Clothing: 890.0}

  // デフォルト付きセーフアクセス
  final booksRevenue = revenue['Books'] ?? 0;
  print(booksRevenue);  // 0
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

8. Bob のシナリオ:DataPipeline の Null 安全性設計

▶ サンプル:オプション設定の Null 安全性設計

DART
class PipelineConfig {
  // 必須フィールド - non-nullable
  final String appName;
  final String version;

  // オプションフィールド - デフォルト付き nullable
  final String? outputPath;
  final String? logLevel;
  final double? customTaxRate;
  final int? maxRetries;

  // オプションから計算 - non-nullable
  String get effectiveOutputPath => outputPath ?? '/tmp/datapipeline/output';
  String get effectiveLogLevel => logLevel ?? 'info';
  double get effectiveTaxRate => customTaxRate ?? 0.08;
  int get effectiveMaxRetries => maxRetries ?? 3;

  const PipelineConfig({
    required this.appName,
    required this.version,
    this.outputPath,
    this.logLevel,
    this.customTaxRate,
    this.maxRetries,
  });

  String get summary => '''
$appName v$version
  Output: $effectiveOutputPath
  Log:    $effectiveLogLevel
  Tax:    ${(effectiveTaxRate * 100).toStringAsFixed(1)}%
  Retries: $effectiveMaxRetries
''';
}

void main() {
  // 最小限の設定 - 必須フィールドのみ
  final minimal = PipelineConfig(
    appName: 'DataPipeline',
    version: '1.0.0',
  );
  print(minimal.summary);

  // 完全な設定
  final full = PipelineConfig(
    appName: 'DataPipeline',
    version: '2.0.0',
    outputPath: '/data/reports',
    logLevel: 'debug',
    customTaxRate: 0.10,
    maxRetries: 5,
  );
  print(full.summary);
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

9. 完全なサンプル:DataPipeline の Null 安全注文処理

DART
// ============================================
// DataPipeline Null 安全注文処理
// Null 安全性パターンの完全な実演
// ============================================

class Customer {
  final String name;
  final String? email;
  final Address? address;

  Customer({required this.name, this.email, this.address});

  String get displayName => email ?? name;
  String get city => address?.city ?? 'Unknown City';
}

class Address {
  final String city;
  final String? state;
  final String country;

  Address({required this.city, this.state, required this.country});

  String get fullRegion => state != null ? '$city, $state' : city;
}

class Order {
  final String id;
  final double amount;
  final String status;
  final Customer? customer;
  final String? discountCode;
  final double? discountPercent;

  Order({
    required this.id,
    required this.amount,
    required this.status,
    this.customer,
    this.discountCode,
    this.discountPercent,
  }) : assert(amount > 0, 'Amount must be positive');

  double get effectiveDiscount => discountPercent ?? 0;

  double get discountedAmount => amount * (1 - effectiveDiscount);

  double get tax => discountedAmount * 0.08;

  double get total => discountedAmount + tax;

  String get customerName => customer?.name ?? 'Guest';

  String get customerCity => customer?.city ?? 'Unknown';

  String get formattedTotal =>
      '\$${total.toStringAsFixed(2)} USD';

  @override
  String toString() =>
      'Order($id, $customerName, ${formattedTotal}, $status)';
}

class OrderProcessor {
  final List<Order> _orders = [];
  final List<String> _warnings = [];

  void addOrder(Order order) {
    _orders.add(order);

    // 警告付きの Null 安全チェック
    if (order.customer == null) {
      _warnings.add('Order ${order.id}: No customer assigned');
    }
    if (order.discountCode != null && order.discountPercent == null) {
      _warnings.add('Order ${order.id}: Discount code without rate');
    }
  }

  double get totalRevenue =>
      _orders.fold(0.0, (sum, o) => sum + o.total);

  List<Order> get ordersWithCustomers =>
      _orders.where((o) => o.customer != null).toList();

  Map<String, double> revenueByCity() {
    final result = <String, double>{};
    for (final order in _orders) {
      final city = order.customerCity;
      result.update(city, (v) => v + order.total, ifAbsent: () => order.total);
    }
    return result;
  }

  void printReport() {
    print('=== DataPipeline Order Report ===');
    print('Orders: ${_orders.length}');
    print('Revenue: \$${totalRevenue.toStringAsFixed(2)} USD');
    print('With customers: ${ordersWithCustomers.length}');

    print('\nRevenue by City:');
    for (final entry in revenueByCity().entries) {
      print('  ${entry.key}: \$${entry.value.toStringAsFixed(2)} USD');
    }

    if (_warnings.isNotEmpty) {
      print('\nWarnings:');
      for (final w in _warnings) {
        print('  $w');
      }
    }
  }
}

void main() {
  final processor = OrderProcessor();

  processor.addOrder(Order(
    id: 'ORD-001',
    amount: 1500.0,
    status: 'completed',
    customer: Customer(
      name: 'Alice',
      email: 'alice@example.com',
      address: Address(city: 'New York', state: 'NY', country: 'US'),
    ),
    discountPercent: 0.10,
  ));

  processor.addOrder(Order(
    id: 'ORD-002',
    amount: 3200.0,
    status: 'completed',
    // customer なし - nullable フィールド
  ));

  processor.addOrder(Order(
    id: 'ORD-003',
    amount: 890.0,
    status: 'pending',
    customer: Customer(
      name: 'Bob',
      address: Address(city: 'London', country: 'UK'),
    ),
    discountCode: 'SAVE20',
    // discountPercent は null - 警告!
  ));

  processor.printReport();
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

出力:

TEXT
=== DataPipeline Order Report ===
Orders: 3
Revenue: $5172.36 USD
With customers: 2

Revenue by City:
  New York: $1458.00 USD
  Unknown: $3456.00 USD
  London: $961.20 USD

Warnings:
  Order ORD-002: No customer assigned
  Order ORD-003: Discount code without rate

❓ よくある質問

Q: 堅牢な Null 安全性と非堅牢の違いは何ですか? A: 堅牢な Null 安全性は、non-nullable 型がプログラム全体で決して null にならないことを保証します(コンパイラがグローバル検証を実行)。非堅牢モード(廃止済み)は、特定のパスがチェックをバイパスすることを許可します。Dart 3 はデフォルトで堅牢です。

Q: nullable 型の代わりにいつ late を使うべきですか? A: 使用前に必ず初期化されることが確実で、null にしたくない変数には late を使ってください。変数が本当に null になれる場合(意味的にオプション)は T? を使ってください。

Q: ! は完全に避けるべきですか? A: 完全にではありませんが、控えめに使ってください。合理的なシナリオには、アサーションの後、テストコード、またはビジネスロジックが明示的に non-null を保証する場合が含まれます。ほとんどの場合、?? または ?. の方が安全です。

Q: null チェック昇格はクロージャ内で有効ですか? A: 常にではありません。変数がクロージャ外で変更される可能性がある場合、コンパイラは昇格を保証できません。ローカル final 変数にキャプチャして解決してください。

Q: ジェネリック型パラメータはデフォルトで nullable ですか? A: いいえ。T はデフォルトで non-nullable、T? は nullable です。これによりジェネリック型の安全性が保証されます。T? は実際には T のスーパークラスです。

Q: JSON パースで多くの nullable フィールドを処理する方法は? A: ?? を使ってデフォルト値を提供するか、json_serializable パッケージを使ってデフォルト値付きの fromJson メソッドを自動生成することを推奨します。あらゆる場所で ! を使うのは避けてください。

Q: late finalfinal の違いは何ですか? A: final は宣言時またはコンストラクタの初期化リストで代入する必要があります。late final はコンストラクタ本体または後続のメソッドで最初に代入できます。late final は代入のタイミングを遅らせます。


📖 まとめ


📝 練習問題

  1. 基礎(難易度 ⭐):5 つの変数(2 つの non-nullable、3 つの nullable)を宣言してください。if-null チェック、???. を使って 3 つの異なる方法で nullable 変数に安全にアクセスし、結果を出力します。
  2. 中級(難易度 ⭐⭐)late を使って 3 つのフィールドの初期化を遅延する Config クラスを設計し、configure() メソッドでそれらを設定してください。意図的に configure() を呼び出さずフィールドにアクセスし、LateInitializationError を観察してください。
  3. 挑戦(難易度 ⭐⭐⭐):すべてのネットワークリクエストが Result<T?> 型を返す NullSafe API クライアントを実装してください。サーバーが null を返す場合、フィールドが欠落している場合、型が一致しない場合の 3 つをすべて ! を使わずに正しく処理します。

← 前のレッスン | 次のレッスン →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%