Dartで学ぶイテレータの基礎と、基本的な使い方

18 min read読了の目安(約16500字

Dartのイテレータに関して基礎的な部分と使い方をまとめてみた。

対象読者は、「Dartを少し使えるけどイテレータは知らなかったりよく分からなかったりする」というくらいの人。ここで言う「少し使える」は「チョットデキル」の意味ではない。

使用したDartのバージョンは2.12.2

イテレータって、なに?

Wikipediaのイテレータのページには、以下のように書かれている。

イテレータ(英語: iterator)とは、プログラミング言語において配列やそれに類似する集合的データ構造(コレクションあるいはコンテナ)の各要素に対する繰り返し処理の抽象化である。実際のプログラミング言語では、オブジェクトまたは文法などとして現れる。JISでは反復子(はんぷくし)と翻訳されている[1][2]。

辞書的な書き方で、正確な記述ではあるけど理解しにくいので、噛み砕いてみるとこんな感じ。

  • 複数のデータをまとめる構造を「コレクション」と呼ぶ。
    • Dartで言えばList、Map、Setといったもの。
  • コレクションの中身のことを「要素」という。
    • 例えばListに入れた1個1個の値が「要素」。
  • コレクション内の要素に対する繰り返し(反復)処理を簡単に書けるようにしたのが「イテレータ」
    • つまりListの中身1個ずつに対して何らかの処理をする、という感じのコードを簡単に書けるようにしている。
    • Mapもコレクションではあるけど、DartではMapは反復できない扱いとなっている。(Map自体はイテレータを持たない)

Dartのコードを書いていると、実はあまり意識しないうちにイテレータを使っていたりする。
例えば、Listの要素を繰り返してprintしたいとき、Dartでは以下のような書き方が代表的だと言われている。

final list = [0, 1, 2];

// for
for (var i = 0; i < list.length; i++) {
  print(list[i]);
}

// forEach その1。普通の関数タイプ。
list.forEach((v) {
  print(v);
});

// forEach その2。ファットアローを使った1行関数タイプ。
list.forEach((v) => print(v));

// for-in
for (var v in list) {
  print(v);
}

この内、実はforEachはイテレータで、Listの親クラスであるIterableのメソッドになっている。

詳しくは後述するけど、このようにループ的な場面で使うのがイテレータ、と思って差し支えはないと思う。

イテレータってどこが便利なの?

コードを書いていると、コレクションの要素を順々取り出して処理する、というのはとてもありふれた操作。これは上記のように、for-inを使ったり、あるいは書くのが面倒な昔ながらのforを使って簡単に対応できる。

でもそこで単なる繰り返しではなく、一定の範囲のものだけを取得したり、何かの条件を満たすものだけを抽出したり、全部の値を累計したり、といったこともやりたくなる。

こんなとき、もちろんループで中身を1個1個見ていくコードを書いてやってもいいんだけど、書いたコードにミスが混入する恐れがあるし、何よりそんなよくある処理を毎度書くのは面倒くさい。

ということで、コレクションに対するよくある処理をすっきり書けるように抽象化する、というのがイテレータ。

ここでいう「抽象化」は、具体的な処理を隠蔽(使う人からは見えないように)した、という意味。つまりあらかじめ用意されているメソッドを使えば、複雑なコードを書かなくてもその機能を利用できる。

この抽象化されたメソッドは何種類もあるけど、それぞれは単純な機能しか持たない。でも複数のメソッドを組み合わせることで、複雑な機能を分かりやすく楽に実現できるのでとても便利。

以降、どんなものかを見ていく。

Iterableクラス

Dartのイテレータの基礎になっているのがIterableクラス。

https://api.dart.dev/stable/2.12.4/dart-core/Iterable-class.html

Iterableクラスは抽象クラスで、イテレータとしての基本的なメソッドが定義されている。

これを継承したサブクラスとして具体的なコレクションのクラス(ListSet等、dart:collectionにある多くのクラス)が定義されて、それらの具体的なクラスでは、それぞれのデータ構造に適したイテレータ関連のメソッドが追加されている。

ということで、Iterableクラスを見てみれば、様々なコレクションに共通するイテレータの基本的な部分を理解できる。

イテレータの具体的な使い方

これからIterableクラスで定義されているイテレータの具体的な使い方を見ていくけど、Iterableは抽象クラスでインスタンスを作れないので、実際に動かすためにサブクラスの1つであるListを使ったコードで見ていく。

Listクラスにしかないメソッドもあったりするけど、ひとまずそれは今回のスコープ外。

あと実はListも抽象クラスで、実際には格納する型ごとに別々のクラスがあったりする。

要素を繰り返すforEach

要素を1個ずつ順々に取り出して、渡された関数で処理していくという、単純なループ処理を行うメソッドがforEach

final list = [0, 1, 2, 3, 4];

list.forEach((v) {
  print(v);
});

内部的にはfor-inをメソッドとしてラップしているだけだったりするので、単なるループ処理を行うもの。

これ、イテレータの中で一番多く使われているような気がするけど、結構特殊なものなので注意が必要。

どういうことかというと、値を返さない戻り値がvoidというメソッドは、Iterableクラスの中にこれしかない。だからこれに囚われて「イテレータってこんなものなんだ」と思ってしまうと他のイテレータを理解しにくくなってしまうという困ったやつ。

あとそもそも、「メソッド(関数)の引数として関数を渡す」というのは高度で難しい概念だと思う。現代だとJavaScriptで頻繁に使われている手法なので一般に浸透している感じがあるけど、それ以前の太古の時代には実装の面倒さもあって限られたシチュエーションでしか使われていなかったテクニック。最近の人はあっさりと理解しているのだろうか?

ということで個人的には、単なるループのときはfor-inを使った方が文脈的に正しいし楽で見やすいので良いと思っている。
この記事の後の部分で書いているような、イテレータで色々した後の中身について最終的に何かをしたいときだけforEachを使うのがいいんじゃないかな。

要素の数を調べる

まずは小手調べということで、イテレータの補助的な働きをするものから。

要素の数や、中身が空かどうかを調べられる。メソッドではなくプロパティ。

final list = [0, 1, 2, 3, 4];
final emptyList = [];

print(list.length);      // -> 5
print(emptyList.length); // -> 0

print(list.isEmpty);      // -> false
print(emptyList.isEmpty); // -> true

print(list.isNotEmpty);      // -> true
print(emptyList.isNotEmpty); // -> false

最初や最後の要素を取り出す

最初や最後、あるいは1個だけを取り出すためのプロパティがある。

空のリストに対して呼び出して、該当する要素がなかったら例外をスローしてくるので、例外をキャッチするか、あらかじめ数を調べておいて範囲をはみ出さないようにする必要がある。

こういう取得系の動作で例外をスローするのは大仰で、「該当する要素がないときはnullを返せば良いのでは?」という気がするけど、それだと要素としてnullを持っているときに判別できなくて困るので、こうした作りになっている。nullがある言語の宿命と言える。

final list = [0, 1, 2, 3, 4];
final emptyList = [];
final singleList = ['only'];

print(list.first); // -> 0
print(list.last);  // -> 4

// print(emptyList.first); // 例外発生!
// print(emptyList.last);  // 例外発生!

print(singleList.single);   // -> only
// print(list.single);      // 例外発生!
// print(emptyList.single); // 例外発生!

しかしsingleの使い道が謎。「要素が1個だけのときに中身を取り出す」というもので、要素が空だったり2個以上だったりすると例外を投げてくる。何に使うんだこれ。

指定した引数を要素に含むかどうかを調べるcontains

ここからはメソッドの紹介。

containsでは、引数と同じ要素がコレクションに含まれているかどうかをboolで返す。

「同じ」というのは「等しい」という意味で、デフォルトのcontainsでは内部で==を使って比較しているので、それぞれのクラスの==の実装にもよるけど、基本的には「同じオブジェクト」ではなく「同じ値を持っている」かどうかを見ている。

final list = [0, 1, 2, 3, 4];
final emptyList = [];

print(list.contains(1)); // -> true
print(list.contains(99)); // -> false
print(list.contains('1')); // -> false (型が異なるので==がfalse)
print(emptyList.contains(2)); // -> false

あと、サブクラスでは==と異なる方法で比較していることがあり得る。基本的にはあまり気にしなくて良い部分ではあるけど。例えばハッシュを使ったマップのキーの比較を行うような場合には影響があることもある、とドキュメントには書いてある。

要素が条件を満たすかを調べるanyevery

イテレータに含まれる要素が、引数として渡された関数の条件を満たすかどうかを調べる。最終的に結果としてboolが返ってくる。

引数で渡す関数は、要素を1つ取ってboolを返すもの。

anyeveryは、以下のような違いがある。

  • any:要素のどれか1つでも条件を満たしていれば、最終的な結果がtrueになる。
  • every:すべての要素が条件を満たしていれば、最終的な結果がtrueになる。(関数が1個でもfalseを返すと、最終的な結果がfalseになる)
final list = [0, 1, 2, 3, 4];

print(list.any((v) => v < 99));   // -> true
print(list.any((v) => v < 3));    // -> true (3より小さい要素が含まれるため)

print(list.every((v) => v < 99)); // -> true
print(list.every((v) => v < 3));  // -> false(3より小さく*ない*要素が含まれるため)

個人的には、ファットアロー=>と比較演算子<=``>=なんかが絡むと見にくいので、普通の関数で書きたくなるところ。

指定したインデックスの要素を取得するelementAt

指定した「n番目」の要素を返す単純なやつ。

intの引数で指定した順番のものを返すけど、引数が負の整数(要するにマイナスの数値)だったり、イテレータの長さ(length)より大きかったりすると、例外を投げてくる。

  final list = ['Zero', 'One', 'Two', 'Three', 'Four'];

  print(list.elementAt(2)); // -> Two
  // print(list.elementAt(-1)); // 例外発生!
  // print(list.elementAt(99)); // 例外発生!

要素を元にして何らかの処理を加え、新しいイテレータを作るmap

ここからがいよいよイテレータの本番。

mapの基本

要素のそれぞれに対して、渡した関数の処理を加えて、新しいイテレータを生成するのがmap。言葉で書くとややこしいけど、コードで見ると単純。

final list = [0, 1, 2];

final result = list.map((e) => e + 2);
print(result.toList()); // -> [2, 3, 4]

このコードでは、元々のリスト[0, 1, 2]の各要素に対して「2を加える」という処理を行って、新しいイテレータを生成して、それを出力している。

最後にあるtoList()は、IterableListへ変換するメソッド。これでIterable<int>だったresultList<int>となる。実は、これがなくてもprintするだけなら問題ないけど、一応付けている。

ということで、mapに対して「1個の引数を取って何らかの値を返す関数」を渡せば、関数の結果から成る新しいイテレータが生成されることが分かる。

これは、元々の要素と別の型を返しても良いことでもある。例えば、加工を加えつつ文字列に変換するようなことができる。

final list = [0, 1, 2];

final result = list.map((e) {
  final tmp = e + 5;
  return 'map $e to $tmp';
});
print(result.toList()); // -> [map 0 to 5, map 1 to 6, map 2 to 7]

すごく便利に使えるのが想像できると思う。夢が広がりんぐ(死語)。

mapでループが書けそうな気がするけど

mapの動きについてもっと考えてみると、単純なループですらmapで書けそうなことに気付く。だってforEachと同じようなことをしているんだし。

例えば以下のようなコードを書けば、リストの値をprintできそうな気がする。

final list = [0, 1, 2];

list.map((e) {
  print(e);
});

でもこれを実行してみれば分かるけど、何も出力されない。

なぜならmapは「lazy」な動きをするから。lazyは直訳すると「怠惰」「動きがのろい」みたいな意味だけど、それではイメージが悪いので日本の業界では格好良く「遅延評価」と呼ぶ。遅延しているのに前向きな雰囲気がある。

lazyといえば

アニヲタ的には「lazy」と聞くと『けいおん!』の「Don't say "lazy"」が第一候補として思い浮かぶのではなかろうか。
澪が歌っているけど一番の怠け者は唯なのでは、とか思ったりする。

このlazyというのは、必要なときにならないと呼び出されない、という意味。イテレータを実行する必要があるときというのは、最終的な結果を得るとき。

すなわち、例えば以下のようにtoList()ですべての結果を得るように書けば、そのタイミングで出力される。

final list = [0, 1, 2];

final lazy = list.map((e) {
  print('map $e');
});

print('Are you lazy?');
lazy.toList(); // ここでmapが実行される。リストへの変換結果は捨てている。

// Are you lazy?
// map 0
// map 1
// map 2

「Are you lazy?」の後で出力されていることから、toList()の時点でmapの処理が実際に走っていることが分かる。

もうちょっと言えば、変数lazyIterable<Null>型なんだけど、それがList<Null>型という別の型に変換されるタイミングで呼び出されている。

イテレータのメソッドをつなげる

また、重要な考え方として、mapがイテレータIterableを返すので、さらにmapを重ねて呼び出せる、ということがある。これはmapに限らず、イテレータを返すメソッドは他にもあり、これらを組み合わせることで、様々な処理を行える。

ひとまずmapだけを使って書いてみると、こんな感じになる。

final list = [0, 1, 2];

final lazy = list.map((e) {
  print('source: $e');
  return e;
}).map((e) {
  final tmp = e + 2;
  print('plus two: $tmp');
  return tmp;
}).map((e) {
  final tmp = e * 2;
  print('and double: $tmp');
  return tmp;
});

lazy.toList();

// source: 0
// plus two: 2
// and double: 4
// source: 1
// plus two: 3
// and double: 6
// source: 2
// plus two: 4
// and double: 8

まずは1段目のmapで元の値をprintしてから返し、2段目のmapで2を足しつつprintしてから返し、3段目のmapで2倍しつつprintしている。

特に意味がない例で、mapだけでは良さが分かりづらいものの、後述する色々なメソッドを組み合わせればとても便利になる。

ちなみにこの場合、出力された順序を見てもらえば分かるけど、「最初の要素がメソッドをずっと流れていって、それが終わったら次の要素がメソッドを流れていって……」という流れになっているのが分かる。ここを「1段ずつ全部処理される」と勘違いしていると、関数の呼び出し順が違っていて副作用でハマったりするので注意。

条件を満たすものだけを取得するwhereとその仲間

whereは、メソッドに引数で渡された「要素を引数にとる関数」がtrueを返したものだけで、新しいイテレータを作るメソッド。動作は遅延評価。
これは要するに条件で要素の絞り込みを行うもので、分かりやすいしとても有用。

final list = [0, 1, 2, 3, 4];

// 偶数だけを取得する。
final result1 = list.where((e) => e % 2 == 0);
print(result1.toList()); // -> [0, 2, 4]

// 奇数を取得して2倍する。
final result2 = list.where((e) => e % 2 == 1).map((e) => e * 2);
print(result2.toList()); // -> [2, 6]

この仲間としてfirstWherelastWhereがある。名前の通り、whereと同様の絞り込みをした結果の中から最初または最後の要素(イテレータではない)を返すもの。
該当する要素がなかったときの動作として、あらかじめ引数で渡した関数の戻り値を返すか、あるいは例外を投げるかを選択できる。

final list = [0, 1, 2, 3, 4];

// 要素を順番に見ていって、初めて条件を満たした値を取得する。
print(list.firstWhere((e) => 10 <= e * 5)); // -> 2

// 要素を順番に見ていって、最後に条件を満たした値を取得する。
print(list.lastWhere((e) => e * 3 < 10)); // -> 3

// 条件に当てはまる要素がなかったとき、orElseに何も指定していないと例外を投げる。
// print(list.firstWhere((e) => 99 <= e)); // 例外発生!

// 条件に当てはまる要素がなかったときに返す値を「返す関数」をorElseで設定する。
print(list.firstWhere((e) => 99 <= e, orElse: () => -1)); // -> -1

あとsingleWhereという、「条件に当てはまるものが1個のときだけその要素を返し、それ以外の場合は指定した関数の戻り値を返すか例外を投げるか」という使いどころの難しいメソッドもある。

いくつか読み飛ばすskipと、いくつか取得するtakeと、その仲間

skipは指定した数だけ要素を読み飛ばして、残りの要素でイテレータを生成する。遅延評価。
要素の最初の何個かは不要なのでスキップしたい、というときに使用できる。

takeは指定した数だけの要素でイテレータを生成する。遅延評価。

どちらも負の値を指定すると例外を投げてくるのはelementAtと同様。
ただ、要素数をはみ出すような大きい値を指定してもどちらも例外にはならない。skipは空のイテレータを返すだけで、takeは取得できた分だけの要素のイテレータを返す。

これらを組み合わせると、「何番目から何個の要素が欲しい」という要望に対応できる。(実はListではgetRangeメソッドで同じことができるので、こういう使い方はしないと思うけど)

final list = [0, 1, 2, 3, 4];

print(list.skip(2).toList());  // -> [2, 3, 4]
print(list.skip(99).toList()); // -> []

print(list.take(2).toList());  // -> [0, 1]
print(list.take(99).toList()); // -> [0, 1, 2, 3, 4]

// 最初の2つを読み飛ばして、そこから2つを取得する。
print(list.skip(2).take(2).toList()); // -> [2, 3]

似たものとして、skipWhiletakeWhileがある。
これは引数で渡された「要素を元にしてboolを返す関数」がtrueの間はskipまたはtakeするもの。

final list = [0, 1, 2, 3, 4];

print(list.skipWhile((v) => v != 2).toList()); // -> [2, 3, 4]
print(list.skipWhile((v) => v < 99).toList()); // -> []

print(list.takeWhile((v) => v < 2).toList());  // -> [0, 1]
print(list.takeWhile((v) => v < -1).toList()); // -> []

要素をまとめるreducefoldjoin

要素を集計したりしてまとめるというのはよくある処理。例えば整数のリストを合計したりとか、文字列としてまとめたいとか。そんなときに便利なメソッドがいくつかある。

reduce

reduceは2つの引数をとる関数を使って、要素を最終的に1つの値にまとめるメソッド。

言葉で説明すると長ったらしくなるので、コードで見るのが簡単。ということで、リストの合計を求めるコードがこれ。

final list = [0, 1, 2, 3, 4];

print(list.reduce((value, element) => value + element)); // -> 10

// 処理の途中でprintしてみる。
list.reduce((value, element) {
  print('$value + $element');
  return value + element;
});
// 0 + 1
// 1 + 2
// 3 + 3
// 6 + 4

途中の変数状態を表示している下側の例を見れば分かる通り、関数に対して以下のような値が渡されている。

  • 1回目:1つ目と2つ目の要素
  • 2回目:1回目の結果と2つ目の要素
  • 3回目:2回目の結果と3つ目の要素
  • 4回目:3回目の結果と4つ目の要素
    関数内で、渡された2つの値を足し合わせているので、最終的には合計された値を得られる。

具体的に何をしているかは、公式のドキュメントに書かれた中身のこのコードを見るのが分かりやすいかも。

E value = iterable.first;
iterable.skip(1).forEach((element) {
  value = combine(value, element);
});
return value;

ここでcombinereduceに渡された関数。
処理として、まず最初の要素をvalue変数に取り出す。それから2個目以降の要素を順番に取り出して、combineに対して前回の結果valueと取り出した要素elementを渡して、combineの結果をvalueに格納する、という処理を繰り返している。
そしてfirstを使っているので、上の方で書いたとおり、要素が1個もなかったら例外を投げてくることが分かる。あとコレクションの要素が1個だけだったらforEachのループに入らず(skipが空のイテレータを返すため)、その値が返されることも分かる。

ちなみに、こうして累計を求める際の、値が累積されていく変数(上記の例だとvalue)をアキュムレータ(Accumulator)と呼ぶことがあって、意味を明確にするために変数名をaccにするようなことがあるので、覚えていたら便利かも。

アキュムレータ……インキュベータ……

個人的にはアキュムレータと聞くと、魔法少女に勧誘する白い悪魔を思い出してしまう。あっちはインキュベータで全然違うんだけど、「キュ」を含む語感が似ているので、つい。

fold

foldは初期値と2つの引数をとる関数を使って、要素を最終的に1つの値にまとめるメソッド。

言葉だけを見るとreduceと初期値があるかないかだけの違いしかないけど、実際はかなり異なる。

例えば上記のreduceと同じようにリストの整数をすべて足し合わせた値を得る場合、以下のようなコードとなる。

final list = [0, 1, 2, 3, 4];

// この書き方だと、型推論でprevが Object? 型となってしまい、コンパイルエラーとなる。
// final result1 = list.fold(0, (prev, element) => prev + element);
// そのため、以下のような解決策をとる必要がある。

// 解決策その1:結果を格納する変数に型を付ける。(int result2)
int result2 = list.fold(0, (prev, element) => prev + element);
print(result2); // -> 10

// 解決策その2:foldへ渡す関数の引数に型を指定する。(int prev)
final result3 = list.fold(0, (int prev, element) => prev + element);
print(result3); // -> 10

// 解決策その3:foldに対してジェネリクスの型Tを指定する。これなら直接printできる。
print(list.fold<int>(0, (prev, element) => prev + element)); // -> 10

コードのコメントとしても書いているけど、型推論が上手く働かないことがある。
これは渡した関数の中の処理にもよるけど、型推論できずに変数をObject?型として扱ってしまい、関数内の処理を解釈できず(上記コードの場合はObject?型に+がないため)、コンパイルエラーとなることがある。
そのため、何らかの方法で型を明確に指定してやる必要がある。

これだけを見るとfoldreduceに比べると型の指定が必要で面倒くさいだけなように思えるけど、実は、元の要素と異なる型へまとめられるという大きなメリットがある。

これは、reducefoldのメソッド定義を見比べてみると分かる。

reduce(E combine(E value, E element)) → E
fold<T>(T initialValue, T combine(T previousValue, E element)) → T

ここで、Eは要素の型で、Tはそれとは異なる型を指し示している。
つまりreduceは、要素と同じ型しか返せない。
対してfoldは、要素とは別の型Tの初期値と、TEの引数からTを返す関数を使って、最終的にTを返せることになる。(もっと型に注目すれば、Tと書いてあるどこかの部分で型を指定すれば、型推論で型が明確になってコンパイルエラーを回避できる、というのが上記の解決策)

これを活用すると色々便利で、例えば以下のコードのようなことができる。

final list = [0, 1, 2, 3, 4];
// 文字列として連結する。あまり意味がないし、初回のループの影響で汚い結果になる。
String s = list.fold('', (String prev, int element) => '$prev, $element');
print(s); // -> , 0, 1, 2, 3, 4


// doubleのリストで、各要素を四捨五入して累計した結果をintで得る。
final list2 = [0.2, 0.5, 1.4, 2.2];
int value = list2.fold(0, (a, b) => a + b.round());
print(value); // -> 4

// reduceを使って同じことはできない。要素の型と最終的な型が異なるため。
// int value2 = list2.reduce((a, b) => a + b.round()); // コンパイルエラー!

// 処理の途中でprintしてみる。
list.fold(0, (int value, element) {
  print('$value + $element');
  return value + element;
});
// 0 + 0
// 0 + 1
// 1 + 2
// 3 + 3
// 6 + 4

最後にfoldの途中経過を出しているけど、reduceに比べて、最初に「初期値と1個目の要素」の処理が走っていることが分かる。だからfoldは要素の数と同じ回数だけ関数が呼び出されるけど、reduceは要素の数より1回だけ少ない。

join

要素を、渡された文字列で連結した文字列として返すのがjoin。語るべきことがないくらい単純。

それぞれの要素をtoString()した結果を連結して返す。

final list = [0, 1, 2, 3, 4];

print(list.join(', ')); // -> 0, 1, 2, 3, 4
print(list.join()); // -> 01234

イテレータ同士を連結するfollowedBy

イテレータ同士を連結したイテレータを返すのがfollowedBy。遅延評価される。

final list1 = [0, 1, 2];
final list2 = [7, 8, 9];
final list3 = ['a', 'b', 'c'];

print(list1.followedBy(list2).toList()); // -> [0, 1, 2, 7, 8, 9]
// print(list1.followedBy(list3).toList()); // コンパイルエラー!

takeskipなんかと組み合わせると、好きなようにイテレータを作れる。

要素の型が異なるイテレータ同士を連結しようとするとコンパイルエラーになる。

おまけ

Mapをイテレータのように扱う

上の方でちらっと書いたけど、コレクションの代表的なものであるMapは、Dartではイテレータを持たない(Iterableクラスのサブクラスではない)扱いなので、そのままではここまで書いてきたような便利なメソッドを使えないことになる。

でもそこは、Mapのキーの一覧をListで取得できる、このListIterableであることを活用してイテレータを使うという方法がある。

例えばこんな感じ。

final m = {0: 'Zero', 1: 'One', 2: 'Two', 3: 'Three'};
// マップの値が3文字のものを抽出する。
print(m.keys.where((k) => m[k]!.length == 3).map((k) => m[k])); // -> (One, Two)

マップmのキー(0~3)を取得して、wheremの値が3文字のキーを抽出し、mapでそのキーを元にして最終的なマップの値のイテレータを返している。

それぞれのメソッドの動作を理解すれば、組み合わせていろいろな処理を書ける。

nullが返ってくるときの捌き方

本筋ではないけど、すぐ上のマップの値が3文字のものを探す例では、whereの中でマップの要素を参照するときに返ってくる型がString?なのでnullが返ってくる可能性があり、そのままではlengthを呼び出せないので、!を付けて強制的にStringへキャストしている。

存在するキーだけでアクセスしているので、m[k]nullを返してくることはないと言えるので、この書き方でも問題はない。もちろん実行時のエラーを避けるために真面目にチェックするような処理もこんな感じで書ける。

final m = {0: 'Zero', 1: 'One', 2: 'Two', 3: 'Three'};

// m[k]がnullを返したときはwhereの中でfalseを返すようにnullチェックする。
print(m.keys.where((k) {
  final v = m[k];
  if (v == null) {
    return false;
  }
  return v.length == 3;
}).map((k) => m[k]));

// 先にマップの値のイテレータにしてしまい、whereTypeでStringのみに絞り込む。
print(m.keys.map((k) => m[k]).whereType<String>().where((v) => v.length == 3));

後者ではwhereType<T>()メソッドが出てきたけど、これはTとして渡された型に一致する要素だけのイテレータを返すもの。

mapm[k]を返すのでString?型のイテレータ(Iterable<String?>)で、whereType<String>()によりString型のみに絞り込んだイテレータを得れば、結果としてnullの値がないIterable<String>になる。

あと、イテレータの結果をprintするとき、ここまでのコードではわざわざtoListしていたけど、Iterableをそのままprintして裏でtoStringを呼び出してもらう、というやり方もある。
これでもprintするだけなら全然問題なくて、少し見た目が変わったり、途中が省略されたり、という違いがある程度。でも、イテレータの結果を戻り値として返すようなときは型の違いに注意が必要。

最後に

思うがままに書いていたら長くなりすぎてしまった。

Iterableクラスのメソッドの動きを理解できれば、そのサブクラスである具体的なコレクションクラスの動作も理解できると思う。

この記事でイテレータの便利さが伝われば嬉しいところ。forで要素をいじくり回すより、イテレータを使って見やすいコードを楽に書けばみんなハッピー!