👷‍♂️

Dartの名前付きコンストラクタとイニシャライザを整理する

2024/04/19に公開

まえおき

近頃Flutterで遊ぶ機会が増えました。確かに新しいフレームワークの学習コストは馬鹿になりませんが、Dart言語がとても良い感覚なので楽しくコーディングできています。

そんな中、扱いが難しいと感じているのがコンストラクタです。
基本的な使い方はさておき、シンタックスシュガーも多く、ある程度腰を据えて学ばないと整理して理解できないトピックではないでしょうか🫠

今回は、名前付きコンストラクタについて学ぶ中で混乱してしまった経験を記事としてまとめようと思います。

実行環境とマシンの情報
$ system_profiler SPHardwareDataType | grep -E 'Model Name|Chip'
Model Name: MacBook Air
Chip: Apple M2

$ sw_vers
ProductName:            macOS
ProductVersion:         14.4.1
BuildVersion:           23E224

$ dart --version
Dart SDK version: 3.2.6 (stable) (Wed Jan 24 13:41:58 2024 +0000) on "macos_arm64"

$ java -version
openjdk version "21.0.2" 2024-01-16
OpenJDK Runtime Environment (build 21.0.2+13-58)
OpenJDK 64-Bit Server VM (build 21.0.2+13-58, mixed mode, sharing)

名前付きコンストラクタとInitializer list

Dartには名前付きコンストラクタ (Named constructors)というものがあります。公式のサンプルコードで確認してみましょう。

const double xOrigin = 0;
const double yOrigin = 0;

class Point {
  final double x;
  final double y;

  // Sets the x and y instance variables
  // before the constructor body runs.
  Point(this.x, this.y);

  // Named constructor
  Point.origin()
      : x = xOrigin,
        y = yOrigin;
}

この部分が大切です:

Sets the x and y instance variables before the constructor body runs.
コンストラクタのbodyが実行される前にインスタンス変数x, yを設定してください

たとえば名前付きコンストラクタの部分を

class Point {
    ...
    Point.origin() {
        x = xOrigin;
        y = yOrigin;
    }
}

と書き換えてみましょう。すると以下の3つのエラーが表示され、コンパイルできなくなります。

All final variables must be initialized, but 'x' and 'y' aren't. Try adding initializers for the fields.
'x' can't be used as a setter because it's final. Try finding a different setter, or making 'x' non-final.
'y' can't be used as a setter because it's final. Try finding a different setter, or making 'y' non-final.

{x = xOrigin; y = yOrigin;}の部分がbodyに相当するため、

  • bodyが実行される前にインスタンス変数x, yが初期化される
    1. 非null変数であるx, yは初期化時の対象となる値がないのでエラー
    2. bodyでfinalな変数であるx, yに値を代入しようとしてエラー

となるわけですね。

ではどうすればよいかというと、Initializer listを使えばOKです。これは上に示したコードにもある

  Point.origin()
      : x = xOrigin,
        y = yOrigin;

のコロン:以降が該当します。もうコンパイルエラーは出ません。心配無用ですね!

…と、この説明は正しいのですが、私はとても違和感がありました。次にその理由を考えてみます。

バイアスとしてのJava

Javaのコードと比較する

私は「JavaScriptっぽいJava」がDartだと思っています。Java経験者であれば文法は違和感ないですし、FutureなんてJavaScriptのPromiseそのまんまです。

JavaScriptのカジュアルなところをピックアップして、Javaの (良い意味で) 堅牢なところを混ぜ込んだ良い言語だと思います[1]

それを踏まえて、類似のコードをJavaで書いて比較してみましょう。

public class Point {
    private final double x;
    private final double y;

    private static final double X_ORIGIN = 0;
    private static final double Y_ORIGIN = 0;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    // オーバーロードしたコンストラクタ
    public Point() {
        this.x = X_ORIGIN; // ← ここ!
        this.y = Y_ORIGIN;
    }

    @Override
    public String toString() {
        return "Point(" + x + ", " + y + ")";
    }

    public static void main(String[] args) {
        System.out.println(new Point()); // Point(0.0, 0.0)
    }
}

こんな感じですね[2]

このJavaコードは問題なく動きます。しかしデバッガーでステップ実行してみると、← ここ!部分で既にPointインスタンスが作られていることがわかります。

つまり、Javaは

  1. コンストラクタが呼ばれた時点で、そのフィールドの型に応じた初期化がなされており
  2. コンストラクタ内に限ってfinalなインスタンス変数に再代入できる

という動作になるわけですね。Dartでは2.が許されていないためにエラーが発生したのでした。

勘違いしていたこと

Javaには名前付きコンストラクタはないので、上述のコードではオーバーロードしたコンストラクタとして定義しています。

私はこれと同じイメージで

🤔 Dartには自由に命名できるコンストラクタがあるのだなあ
😊 シグネチャとは無関係に区別できるのは便利だなあ

程度の理解をしていました。しかしこれは違うようです。

改めてInitializer listを考える

以上の議論を踏まえるとInitializer listとは

  • コンストラクタの呼び出し時にインスタンスが生成される際の初期値を指定する

ものであることがわかります。このようなインスタンス生成に伴う初期化処理を実行するものをイニシャライザ (Initializer) といいます。ちょっと影は薄いですが、Javaでも一応あります。

そのため、コンストラクタのbodyで代入すると再代入する扱いになるわけですね。

一方、コンストラクタ自体のbodyも実行されることに注目すると、Initializer listの;の代わりにbodyを記述して

Point.origin()
    : this.x = xOrigin,
      this.y = yOrigin {
    print('Point.origin() constructor called🚀');
}

のように処理させても問題ありません。

どうしてもコンストラクタでfinalな変数を初期化したい!という場合はlateキーワードを使いましょう。

class Point {
  late final double x;
  late final double y;

  Point.origin() {
    this.x = xOrigin;
    this.y = yOrigin;
  }
}

ここでのx, y

  1. Point.originで遅延評価するものとして初期化される
  2. コンストラクタbody内の初回の代入のみが許される

ということになっているので、もし別の場所で再代入しようとすると例外 (LateInitializationError) がスローされます。

ただ、2.の代入はコンストラクタ内である必要はありません。たとえば

class Point {
  late final double x;
  late final double y;

  Point.origin() {
    // this.x = xOrigin;
    this.y = yOrigin;
  }
}

main() {
  final p = Point.origin()..x = 3;
  print('x: ${p.x}, y: ${p.y}'); // x: 3.0, y: 0.0
}

というコードは問題なく動きます!

が、xの初期化を忘れてしまうと例外がスローされます🙄

main() {
  final p = Point.origin();
  print('x: ${p.x}, y: ${p.y}'); // LateInitializationError
}

この危険性を考慮すると、安易にlateを使うべきではありませんね[3]。可能な限りInitializer listを使いましょう!

まとめ

本記事では、Dartにおける名前付きコンストラクタとInitializer listについての整理を行いました。
Dartのコンストラクタは柔軟ではあるものの、学習初期に戸惑いやすいトピックの一つです。
多言語の経験がない方が手を出す言語ではないので、その意味でもバイアスも生じやすいのではないでしょうか🥲

以下に記事のポイントをまとめておきます。

  1. 名前付きコンストラクタとInitializer list

    • Dartでは名前付きコンストラクタを定義できる
    • 通常のコンストラクタを含め、そのbodyではfinalな非null変数を初期化することはできない
    • finalな非null変数を初期化するためには、Initializer listを使用する
  2. コンストラクタ内でfinalな非null変数を初期化する方法

    • lateキーワードを付すことで、コンストラクタ内でもfinalな非null変数を初期化できる
    • しかし、これは不適切な使用はバグの原因ともなるため、使用時には注意が必要
脚注
  1. ただ、Javaの検査例外は好きな仕組みなので入れてほしかったです。 ↩︎

  2. なお、オーバーロードしたコンストラクタはもちろんpublic Point() {this(X_ORIGIN, Y_ORIGIN);}でも問題ありません。というよりこちらの方がベターですが、あえてこの形にしています。 ↩︎

  3. lateには高コスト変数の初期化を必要時まで遅延できる、というメリットがあります。このようなケースは積極的に使うべきです。 ↩︎

Discussion