0
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Flutter入門 第6回】Dart の Null Safety ― null 安全を理解して安全なコードを書く

0
Posted at

はじめに

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 に起因する実行時エラーを大幅に削減できます。! の多用は避け、?.?? を使って安全にコードを書くことを心がけましょう。


参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?