118
88

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Dartの型の理解しておきたいあれこれ

Last updated at Posted at 2020-08-28

今回は Dart の基礎の一部を記事にしてみました。オブジェクト指向にも関わるところです。
応用的なところまで広く含んでいて、時々見返して思い出すのにも使えると思います。

本記事に含めていた null safety に関する部分は別記事に分離しましたので、あわせてお読みください。

型の比較

あまり気にしたことがない方が多いかもしれませんが、とても重要です。
しっかりと理解しておきましょう。

オブジェクトと型の比較

is を使います。

class Person {}
class Mario extends Person{}
class Luigi extends Person{}
final mario = Mario();
print(mario is Mario);  // true
print(mario is Luigi);  // false

もし == を使ってしまうと、間違った比較方法なので常に false になります。
analysis_options.yaml で unrelated_type_equality_checks のルールを有効にしておくと 「Equality operator == invocation with references of unrelated types.」と警告してくれて安全です。

print(mario == Mario);  // false
print(mario == Luigi);  // false

is の否定形は is! です。
!is ではありません。

print(mario is! Mario);  // false
print(mario is! Luigi);  // true

Null 型

Dart では null にも型があり、Null というクラスとして定義されています。
そのコードは下記のとおりです。

class Null {
  factory Null._uninstantiable() {
    throw UnsupportedError('class Null cannot be instantiated');
  }

  external int get hashCode;

  /** Returns the string `"null"`. */
  String toString() => "null";
}

null は値がないことを表すものですよね。
それなのに null.toString() が成り立つのは、Null クラスが toString() というインスタンスメソッドを持っていて、nullNull 型の(唯一の)インスタンスだからです。

また、型があるため、先ほどと同様の型比較が一応可能です。

print(null is Null);  // true

ただし、このように比較すると「Tests for null should be done with '== null'.」という警告が出ます。
この警告メッセージに書かれているとおり == で比較するようにしましょう。

int v;
print(v == null);  // true

型同士の比較

「オブジェクト vs 型」でなく「型 vs 型」の比較なら普通の比較なので等価演算子(==)を使います。

print(Mario == Mario);  // true
print(Mario == Luigi);  // false

「Type 型という値 vs Type 型という値」とも言えます。
下記の t1 と t2 はどちらも Type 型です。

final t1 = Mario;
final t2 = Mario;
print(t1 == t2);  // true

親との比較

Mario というクラスは Person を継承しています。
Mario のオブジェクトは Mario 型であると同時に Person 型でもあるはずです。

検証してみましょう。

final mario = Mario();
print(mario is Mario);   // true
print(mario is Person);  // true

思ったとおりですね。
これを使えば、あるクラスを継承しているかどうかを判定できることになります。

では型同士の比較はどうでしょうか?

print(Mario == Mario);   // true
print(Mario == Person);  // false

Mario 型は Person 型ではないと判定されました。
継承関係は考慮されず、単純に同一の型かどうかという判定になります。

Object 型

Object はあらゆるクラスの基底となるものです。
つまり、次のように is Object という比較の結果は常に true となります(Dart 2.12 未満)。1

※Null 安全においては Null 型はこれに該当しません(詳細は null safety 編 をご覧ください)。

print(mario is Object);  // true
print(10 is Object);     // true
print(null is Object);   // true(Dart 2.12 以降では false)

Object が基底となっていることを知っていると仕組みを理解しやすくなる場合があるかもしれません。

現在の型を得る

ランタイムの型を取得するには runtimeType を使います。
Dart のあらゆるオブジェクトの基底クラスとなっている Object クラスが持つプロパティで、Type 型です。

final mario = Mario();
final Person person1 = Mario();
final person2 = Mario() as Person;  // Unnecessary cast. という警告はここでは無視で

print(mario.runtimeType);    // Mario
print(person1.runtimeType);  // Mario
print(person2.runtimeType);  // Mario

親クラスの型の変数に入れたり明示的にアップキャストしたりしても Mario 型のままになります。
つまり

print(person1.runtimeType == Mario);   // true
print(person1.runtimeType == Person);  // false

// Type型なのでisでの比較はダメ
print(person1.runtimeType is Mario);   // false
print(person1.runtimeType is Person);  // false

となります。

継承の確認に runtimeType を用いてしまうミスもやってしまいがちです。
気を付けましょう。

if (mario.runtimeType is Person) { ... }  // ダメ
if (mario.runtimeType == Person) { ... }  // ダメ

正しい方法は下記です(親との比較 のところで見たとおりです)。

if (mario is Person) { ... }

型チェック

Mario クラスだけにメソッドを持たせてみます。

class Mario extends Person {
  void greet() {
    print("Hi! I'm Mario.");
  }
}

先ほど person1person2runtimeTypeMario のままでした。
それなら Mario クラスだけが持つメソッドを呼べそうな気がしなくもないです。どうでしょうか。

mario.greet();    // Hi! I'm Mario.
person1.greet();  // ダメ
person2.greet();  // ダメ

ダメですね。
runtimeType では Mario 型となっても中身は Person 型であり、Mario のメソッドにはアクセスできません。

この場合、方法は二つあります。

// 方法1
(person1 as Mario).greet();

// 方法2
if (person1 is Mario) {
  person1.greet();
}

方法 1

Mario 型にダウンキャストする方法です。
これはキャストできないものに対して使ってしまうと例外が発生しますのでご注意ください。
null の場合も例外が発生します。2

方法 2

型をチェックする方法で、こちらのほうが安全です。
「チェック」という表現にしていますが、先述の比較と同じです。
is を使って Mario として扱える型(Mario 自体かその派生)であることをチェックしただけです。

ポイントは、チェックが済んでいる位置では Dart が「person1Mario 型だ」として扱ってくれるようになって、Mario にしかない greet() が利用可能になるという点です。

ジェネリクス

ジェネリクスはまさに型に関するものです。

ここでジェネリックなピーチ姫の登場です。
活用例としてはいまいちですが、親しみやすそうな例としてこれにしました。

class Peach<T> {
  T _husband;

  void marry(T husband) {
    _husband = husband;
  }

  void speak() {
    print("I'm of type $runtimeType, and my husband is of type $T.");
  }
}

夫を入れておく _husband の型としてジェネリック型 T を使います。
ピーチ姫は Peach<T>T で指定した型の人と結婚できるということです。

例えばジェネリック型として Person を指定する(Peach<Person> と書く)と、Peach クラス内で

Person _husband;

void marry(Person husband) {
  _husband = husband;
}

と書かれているのと同じ意味になります。
ピーチ姫にとって結婚相手なんて「人なら誰でもいいわ」という状態です。
マリオさんは Person を継承しているので marry() に渡すことができます。

Peach<Person>()
  ..marry(mario)
  ..speak();  // I'm of type Peach<Person>, and my husband is of type Person.

結婚できる型を絞って Mario 型にしてみましょう。
「マリオさんしか嫌!」というピーチ姫です。

Peach<Mario>()
  ..marry(mario)
  ..speak();  // I'm of type Peach<Mario>, and my husband is of type Mario.

「ルイージさんしか嫌!」とするとマリオさんは結婚相手の条件から外れます。

Peach<Luigi>().marry(mario);  // marioはLuigi型でもその派生型でもないのでダメ

雰囲気が掴めましたか?

<T> のところは <T extends RichPerson> のように継承関係を指定することや <T, U> など複数を指定することもできます。
慣れたらご自分で試してみてください。

型を変数に入れる

型自体を変数に代入したいときがあります。
先ほども Mario という型を変数に入れました。

final t = Mario;

これは

final Type t = Mario;

ということです。
Type という型の変数に型自体(を表す値)を入れておくことができるわけです。

runtimeTypeType 型で、変数に普通に代入できます。

final mario = Mario();
final t = mario.runtimeType;

ジェネリクスの場合

しかしジェネリクスを使ったクラスの型はなんと単純な代入ができません。
Dart の言語仕様による制限です。

Dart 2.15 でできるようになりました。
詳細情報

サポートされる前の情報(クリックで開閉)
final Type peachType = Peach<Mario>;  // ダメ

これを可能にする方法が Stack Overflow にありました。

Type typeOf<T>() => T;

main() {
  Type type = typeOf<MyClass<int>>();
  print(type);
}

flutter - How to get generic Type? - Stack Overflow

少しややこしいのですが、

  • ジェネリック型 T を受け取って Type 型として T を返すメソッド typeOf() を用意
  • そのメソッドの戻り値は Type 型だから Type 型の変数に代入できる

という Hack です。
これを先ほどの Peach<T> でもやってみましょう。

Type typeOf<T>() => T;
final peachType = typeOf<Peach<Mario>>();
print(peachType);  // Peach<Mario>

できました。

ついでですが、引数で型を渡したいときにわざわざ typeOf() を用意したくなかったらどうしますか?

void printType(Type type) {
  print(type);
}

渡す関数はこうなっているとします。下の答えを見ずに、どうやって渡すかちょっと考えてみてください。

・・・

自力でできましたか?

printType((<T>() => T)<Peach<Mario>>());  // Peach<Mario>

このようにできますが、可読性が低くなりますからやはり typeOf() を用意するほうが良いでしょう。

型エイリアス

型には typedef でエイリアス(別名)を付けることができます。
この機能はもともと関数型にしか使えませんでしたが、Dart 2.13 にて他の型にも使えるように拡張されました。3

ポイントは、あくまで別名だという点です。
intMyInt という別名を付けるとすると、次のようにその二つの型を使っても実際には同一です。

typedef MyInt = int;

void main() {
  final int value1 = 10;
  final MyInt value2 = 10;

  // 値同士の比較
  print(value1 == value2);    // true

  // 値と型の比較
  print(value1 is MyInt);     // true
  print(value2 is int);       // true

  // 型同士の比較
  print(MyInt == int);        // true

  // 型をprint
  print(value2.runtimeType);  // int

  Type typeOf<T>() => T;
  print(typeOf<MyInt>());     // int

  // 元の型へのキャスト
  print(value2 as int);       // Unnecessary cast. という警告が出る
}

長い型に短い別名を付けて元の型と同じように使えるのは便利です。
例えば、Map<String, List<int>>IntListMap という短い名前にして使うと記述が少しすっきりします。

typedef IntListMap = Map<String, List<int>>;

void printMapSum1(IntListMap m) {
  print(m.map((k, v) => MapEntry(k, v.reduce((a, b) => a + b))));
}

...

final map1 = {'a': [1, 2, 3], 'b': [4, 5, 6]};
printMapSum1(map1);  // {'a': 6, 'b': 15}

ジェネリクスを組み合わせることもできます。

typedef NumListMap<T extends num> = Map<String, List<T>>;

void printMapSum2(NumListMap<double> m) {
  print(m.map((k, v) => MapEntry(k, v.reduce((a, b) => a + b))));
}

...

final map2 = {'a': [1.1, 2.2, 3.3], 'b': [4.4, 5.5, 6.6]};
printMapSum2(map2);  // {a: 6.6, b: 16.5}

型エイリアスは型の意味を名前でわかりやすくする効果もあります。
上の例では型をそのまま説明する名前にしましたが、もっと用途を理解しやすい名前にすると良いと思います。

ただし、用途を説明することはできても用途を限定することまではできません。
例えば価格に使う型ということで intPrice という別名を付けても Price 以外の int にも使えてしまいます。

typedef Price = int;
typedef Tax = int;

...

final Tax tax = 10;
final Price price = tax;

ちゃんと区別したいなら独自の型(クラス)を作るほうが良いでしょう。

オーバーライド

メソッド等の戻り値や引数をオーバーライドしたい特殊なケースが稀ながらあり得ます。
必要となったときに読み返せるようにまとめておきます。

メソッドやゲッターの戻り値

親クラスのメソッドやゲッターをオーバーライドするとき、戻り値を派生型に狭めることができます。

下記の例ではオーバーライドした側で num の派生型である int にしています。

class Mario {
  final _height = 175.5;

  num get height => _height;
}

class Mario2 extends Mario {
  @override
  int get height => _height.toInt();
}

この逆(親側が int、子側が num)はできないようです。

メソッドやセッターの引数

メソッドやセッターの引数をオーバーライドするときは基底型に変えることができます。
戻り値とは逆なので注意が必要です。

class Mario {
  num _height;

  set height(int height) => _height = height;

  ...
}

class Mario2 extends Mario {
  @override
  set height(num height) => _height = height;
}

covariant

covariant というキーワードを付ければ引数を派生型に変えることもできます。
付けるのは親側でも子側でも OK です。

class Mario {
  num _height;

  set height(covariant num height) => _height = height;

  ...
}

class Mario2 extends Mario {
  @override
  set height(int height) => _height = height;
}

オブジェクトの同一性判定

オブジェクトが同一かどうかを判定できるようにしたい場合、これもまた型が関わってきます。

オブジェクトを比較可能にするには、演算子をオーバーライドして比較の条件を自分で設定します。
例えば等価の判定には等価演算子をオーバーライドします(同時に hashCode のオーバーライドも必要になります)。4 5

class Person {
  Person(this.name);

  final String name;
}

class Mario extends Person {
  Mario() : super('Mario');

  @override
  bool operator ==(Object other) =>
      identical(other, this) || other is Mario && name == other.name;

  @override
  int get hashCode => Object.hashAll([name]);
}

class Luigi extends Person {
  Luigi() : super('Luigi');
}

同一性を判定できる機能を Mario に持たせました。
こうしておけば次のように等価演算子で比較できます。

print(Mario() == Mario()); // true
print(Mario() == Luigi()); // false

bool operator ==(Object other)other には、上記のように比較したときの右辺の値が渡されます。

  • identical(other, this)
    • 両辺で同じオブジェクトを参照しているかどうか(無しでも動作します)
  • other is Mario
    • 右辺も Mario 型かどうか
    • その後ろで other.name が使えるようにするための型チェックでもあります
      • 「どういうこと?」と思った方は 型チェック のところを読み直してみてください
  • name == other.name
    • 両辺の name が同一かどうか

つまり「型が Mario」かつ「名前が一致」していれば同じ人物だと判定されます。

判定のカスタマイズ

ちょっと変えて、口ひげがある人なら誰でもマリオになる世界にしてみましょう。
これもなるべく自力でやってみてください。

・・・

できましたか?

では答えの一例です。
hasMustache というフラグを Person クラスに持たせました。

class Person {
  Person(this.hasMustache);

  final bool hasMustache;
}

class Mario extends Person {
  Mario({bool hasMustache = false}) : super(hasMustache);

  @override
  bool operator ==(Object other) =>
      identical(other, this) || other is Person && hasMustache && other.hasMustache;

  @override
  int get hashCode => Object.hashAll([hasMustache]);
}

class Luigi extends Person {
  Luigi({bool hasMustache = false}) : super(hasMustache);
}

Person 型かそれを継承した型」かつ「口ひげがある」ことを同一性の判定条件にしました。

final mario = Mario(hasMustache: true);
final luigi = Luigi(hasMustache: true);
print(mario == luigi);   // true

final mario2 = Mario();
print(mario == mario2);  // false

final mario3 = Mario();
print(mario2 == mario3); // false
  • 口ひげがある Mario と口ひげがある Luigi
    • 同一人物
  • 口ひげがある Mario と口ひげがない Mario
    • 別人
  • 口ひげがない Mario と口ひげがない Mario
    • 別人

まるで同一人物のようであっても「口ひげがある」という条件に該当しなければ別人と判定されます。
こんな変わった比較はあまりしないと思いますが、そんなこともできますよという例でした。
型の面白さが見えたでしょうか?


以上です。
null safety に特化した続編もご覧ください。

参考

  1. 必ず true となる比較なのでわざわざするのは意味はなくて無駄ですが、説明用にあえて比較しています。「Unnecessary type check, the result is always true.」という警告が出ます。

  2. null(person1 as Mario)?.greet() のように ?. を使えば対策できます。

  3. https://medium.com/dartlang/announcing-dart-2-13-c6d547b57067

  4. オーバーライドの対象は、基底クラスたる Object クラス が持つ比較演算子と hashCode です。

  5. Object.hashAll([...]) は Dart 2.14 で導入されました。他に Object.hash([object1, object2, ...])Object.hashAllUnordered([...]) があります。

118
88
9

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
118
88

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?