実践Flutter

Flutterでスマホを中心にアプリ制作していきます。

実践Dart:コレクション

はじめに

本記事ではデータ構造として必須のコレクションフレームワークについてまとめていきます。

コレクションフレームワークというのは、データの集まりを扱うものでモダンな言語ではどれもリスト(配列)、セット(集合)、マップ(写像)を用意しています。

殆どのソフトウェアはなんらかのデータの集まりを扱うものになりますので、うまくコレクションを扱うことは必須になります。

今回はまず基本的な扱い方のポイントを見ていきます。

上級になると、その背後にあるアルゴリズムを適切に選択することで、大量のデータを効率よく扱うことができるようになってきます。

ただ大きく影響するのはデータ数が数千から数万規模以降ですので、スマホアプリ開発ではあまり気にしなくて良いかも知れません。こだわれば、レスポンスを改善できる場面もあるかもですけれども。一旦気にしないでいきましょう。





リスト

Dartはいわゆる他言語のもつプリミティブな「配列」はなく、リストを用います。基本的に同じものですので特に困らないところです。配列はあったとしても普通使うのはリストだったりしますからね。

C言語から配列がプリミティブ型として用意される伝統みたいなものがありますが、配列をリッチにしたものが基本的にリストとして与えられるクラスになります。

ただDartはシンタックスシュガーとしていわゆる配列っぽい表記がたくさん用意されていて、リストを使っているから表記が冗長になってしまうということもありません。Dartではこの配列風の記法が非常によく用いられます。

クラス表記はメジャー言語と同じ「List」でT型のリストを表します。int型のリストであればListですね。

下記はリストの初期化の例です。[xx, xx]表記で他の言語のプリミティブな配列風の表記ですが、これはListのコンストラクタになっています:

//int型のリスト。List<int>型。
var ilist = [1, 2, 3, -1];

//String型のリスト。List<String>型になる。
var slist = ["Prius", "Yaris"];

Listクラスに特別に用意された初期化記法というのもなにげにすごい話しですけれどもね。それだけ特別に主役のクラスなので許されるのでしょう。Flutterでこの初期化は多用されますので、これがないと大変です。

リストには様々な操作が用意されていて、これらを使いこなしていくことが重要です。下記は定番のメソッドの例です:

void main(){
  var list = [1, 2, 3, 4];
  
  print(list);  //[1, 2, 3, 4]
  
  //最後に5を追加
  list.add(5);
  print(list);  //[1, 2, 3, 4, 5]
  
  //すべての要素をクリア
  list.clear();  
  print(list);  //[]
  
  //リストが空であれば真
  print(list.isEmpty);  //true
  
  //すべての要素を追加
  list.addAll([2, 4, 6]);
  print(list);  //[2, 4, 6]
  
  //逆順のイテレータを取得(リスト自体には変化なし)
  print(list.reversed);  //(6, 4, 2)
  
  //2が含まれれば真
  print(list.contains(2));  //true
  
  //indexが2の要素(一番前が0なので、2だと3番目)
  print(list[2]);  //6
  print(list.elementAt(2));  //6  同じ意味

  //部分リストを返す。sublist(開始インデックス[, 終了インデックス(これの1つ手前まで)]);
  var sublist = list.sublist(1, 2);
  print(list);  //[2, 4, 6]  元のリストは変わらない
  print(sublist);  //[4]  部分リスト
}

リストでよく使うのはadd、addAll、isEmptyくらいでしょうか。リストでcontains操作を行うと線形探索になるので、うかつに使うとパフォーマンスに影響が出ます。contains操作が頻発する場合は集合の活用を検討したほうがよいでしょう。


スプレッド演算子

Flutterでよく使われるリスト関連演算子にスプレッド演算子(...)があります。

スプレッド演算子はリスト中で使うことで、リストの要素を展開して追加する形になります:

void main(){
  var list1 = [1, 2, 3, 4];

  //listの要素を追加
  var list2 = [5, 6, ...list1];

  //途中に追加することも可能
  var list3 = [8, 9, ...list1, 10, 11];
  
  print(list1);   //[1, 2, 3, 4]
  print(list2);   //[5, 6, 1, 2, 3, 4]
  print(list3);   //[8, 9, 1, 2, 3, 4, 10, 11]

}

もし追加するリストの要素がnullableである場合は「...」の代わりに「...?」を使います。もしnullであれば展開して追加の操作が行われず、なにも追加されません。特に分岐せずにnullを回避できる「?」追加系の演算子は便利ですよね。


コレクションif

コレクションの定義の中にifによる条件文を含めることができます。これもFlutterでよく使う配列表記によるGUI構築のなかで条件判断を配列の中で書けると非常に楽なので頻繁に使われます:

void main(){
  String maker = "Toyota";
  
  var cars = ["Prius",
             "yaris",
             "Aqua",
             if(maker == "Toyota") "Crown",
             if(maker == "Honda") "Civic"];
  
  print(cars);  //[Prius, yaris, Aqua, Crown]
}

上では2つあるif文のうち、上しか真にならないので"Crown"のみが追加されています。リストの初期化子のなかにif文を混ぜ込めるのはなかなかの表現力です。


コレクションfor

コレクションifと同様、for文もリストプリミティブの定義中に使えます

void main(){
  String maker = "Toyota";
  
  var cars = ["Prius",
             "yaris",
             "Aqua",
              for(var i=0; i<5; i++) "Crown"+i.toString(),
              "CH-R"
              ];
  
  print(cars);  //[Prius, yaris, Aqua, Crown0, Crown1, Crown2, Crown3, Crown4, CH-R]
}

for()の後の式を繰り返しの数だけ生成してリストに加えていきます。

Flutterではあるデータの要素文、GUIのパーツを足していくなどの操作が頻繁にありコレクションifとコレクションforが非常に便利な場面が多くありますので要チェックです。


集合

リストListは要素に順序のある物の集合でした。これに対して順序のない物の集まりを集合Setで扱います。

基本的に集合は要素の重複を扱いません。同一のオブジェクトを同じ集合に入れても、数が増えない仕組みになっています。この点もリストとは違うポイントです。

リストの[]による初期化のように、集合も{}でくくることでプリミティブ型のように初期化できます:

void main(){
  var cars = {"Prius", "Yaris", "Aqua", "Yaris"};
  print(cars);   //{Prius, Yaris, Aqua}
}

ここでは敢えて"Yaris"を重複させて入れてみましたが、printした結果は一つしか入っていないことがわかります。

集合を初期化する場合、注意すべきポイントがありますので書き方をいくつかあげます:

void main(){
  //intの集合の初期化
  Set<int> set1 = {};
  set1.add(1);
  
  //{}に型を付けてintの集合を初期化
  var set2 = <int>{};
  set2.add(2);
  
  //★注意!これは集合ではなく、写像(Map<dynamic, dynamic>)になる
  var set3 = {};
  //コンパイルエラー
  set3.add(3);
  
  //これが一番他言語からすると普通に見えるが、「リテラルを使えるなら使え」とコンパイラが警告
  var set4 = Set<int>();
  set4.add(4);
}

ここで3番目はSetではなくMapになることに注意してください。写像は次節でとりあげますが、空集合と空写像は両方{}になるため、区別がつかないのです。

それなら{}は空集合にしてくれてもいいのに、と思うのが普通の感覚ですが、写像なんですよね。なぜかというと伝統的にプログラミング言語のライブラリの実装では、集合は写像の一部として(具体的には写像のキーの集合として)実装されています。知りたい方はライブラリのソースコードを見てみましょう。勉強になりますよ。なので写像の方が優先度が高いのでしょうね。

そして4番目、この形が普通かなと思ったのですが、なんとコンパイラは「リテラルで表現できるならばリテラルを使うように」と警告を出します。JavaやC#などの出身者はこの4番目推しなのではと思いますが、おそらく一番Dartが推しているのは2番目でしょうね。一番Dartらしい表現ですので。


ちなみにSetはそもそもabstractクラスなので、これを初期化するというのは違和感がありますよね。これはabstractクラスにデフォルトのコンストラクタを仕込んでおけるというトリックがあって解決しています。

ちなみにDartのSetのデフォルト実装はLinkedHashSetになっています。なので、同一の要素は1つにまとめられるものの、基本的に要素の順番は追加された順が保存されます。(HashSetではないのが今風ですよね)

void main(){
  var set1 = <int>{};
  var set2 = <int>{};
  
  set1.addAll([1, 2, 3, 4, 5, 1, 2, 3]);
  set2.addAll([5, 4, 3, 2, 1, 2, 3, 4]);
  
  print(set1);   //{1, 2, 3, 4, 5}
  print(set2);   //{5, 4, 3, 2, 1}
}

追加した順序が保存されていますね!


hashCodeと==の実装

自前のクラスを集合の要素とするとき、hashCodeと==演算子の実装を適切に行う必要があります。これは同一のインスタンスかどうかを判定するときにデフォルト(Objectクラスの同一性定義)では同じアドレスのインスタンスのみが同一とされる実装になっているからです。

しかし実際には多くの場合、クラスに含まれる変数値が同一であれば、同一とみなしたいことが多く、そういう場合はhashCodeと==を適切にオーバーライドする必要があります。

もしこれらをデフォルトのままにした場合が下記の通りです:

class Car {
  final String name;
  final String engine;
  
  Car(this.name, this.engine);
  
  void printCar(){
    print(name+" "+engine);
  }
}

void main(){
  var c1 = Car("Prius", "Hybrid");
  var c2 = Car("Yaris", "Normal");
  var c3 = Car("Prius", "Hybrid");  //c1と同じ内容
  
  var cars = <Car>{c1, c2, c3};
  
  for(var c in cars){
    c.printCar();
  }
}

これを実行すると、以下のように表示されます:

Prius Hybrid
Yaris Normal
Prius Hybrid

しかしc1とc3はインスタンスは違えど中身が同一なので、これは同一視して集合のなかでは一つだけにしたいとします。このときはこれらを同一であるということをhashCodeと==のオーバーライドで示す必要があります。

hashCodeは同一視するインスタンス同士であれば同一の値を返すように(違う場合に同一の値を返してもOKだが、あまりに値が重なると検索性能がその分落ちる)、==は中身の変数が全て同一であれば真になるように実装します:

class Car {
  final String name;
  final String engine;
  
  Car(this.name, this.engine);
  
  void printCar(){
    print(name+" "+engine);
  }
  
  @override
  int get hashCode{
    return name.hashCode+engine.hashCode;
  }
  
  bool operator ==(Object other){
    if(identical(this, other)){
      return true;
    }
    if(other is Car){
      return name==other.name && engine==other.engine;
    }else{
      return false;
    }
  }
  
}

void main(){
  var c1 = Car("Prius", "Hybrid");
  var c2 = Car("Yaris", "Normal");
  var c3 = Car("Prius", "Hybrid");  //c1と同じ内容
  
  var cars = <Car>{c1, c2, c3};
  
  for(var c in cars){
    c.printCar();
  }
}

これを実行すると、以下のように表示されます:

Prius Hybrid
Yaris Normal

1番目と3番目が同一視されましたね。

ただし、hashCodeと==演算子をオーバーライドするのは不変なオブジェクト(全てのインスタンス変数はfinal)であるときに限って行います。もし可変オブジェクトに対してhashCodeを途中で変更すると、hashCodeを元に作成したhashテーブルが壊れてしまうためです。

慎重に設計していけばこのようなことは起こらないと思いますが、念の為注意です。なるべく不変であるべき値を不変にしていけば、自然にこの注意には抵触しないかと思います。


写像

写像MapはK型のキーとV型の値の組です。Mだとキーがint型、値が文字列型の写像になります。

Map関連の初期化と操作の例をみていきます:

void main(){
  var map = <int, String>{
    1: "Prius",
    2: "Yaris",
    5: "Crown",
  };
  
  print(map[5]);  //Crown
  print(map[6]);  //null  (キー・値が設定されていない)
  
  //キー・値の追加。もしキーが存在していない場合に追加される。
  map.putIfAbsent(4, ()=>"Aqua");
  map.putIfAbsent(2, ()=>"CH-R");
  
  print(map[4]);  //Aqua
  print(map[2]);  //Yaris  2の方は既に存在していたので変わらず。
  
  //キー・値の追加。こちらの形式はキーが存在しても上書きされる。
  map[6] = "Passo";
  map[2] = "Roomy";
  
  print(map[6]);  //Passo
  print(map[2]);  //Roomy  上書きされている。 
}

最初の宣言で{1→"Prius", 2→"Yaris", 5→"Crown"}の写像が定義されます。値の参照やキー・値の追加設定は配列のような文法で扱うことができます。

putIfAbsentはその名の通り、キーが不在ならば追加するというメソッドになります。あまりほかのプログラミング言語のライブラリでは見ない形ですね。値の与え方もコールバック関数形式なので注意です。


コレクション操作メソッド

コレクションには便利なメソッドが色々と用意されています。

where」は全ての要素を回って与えられたコールバック関数を適用し、この関数が真を返す要素のイテレータ(Iterable)を返します。ここでコールバック関数の型はListに対してbool Function(T)です。

void main(){
  var list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
  
  //nが4より大きい場合のみ。toList()をつけるとリストを生成
  var l1 = list.where((int n)=> n >4).toList();

  //nが3で割り切れるもののみ真。toListを付けていないので結果はIterable<int>
  var l2 = list.where((int n)=> n % 3 == 0);

  print(l1);   //[5, 6, 7, 8, 9, 10]
  print(l2);   //(3, 6, 9)   これはリストではなくIterable<int>
  
  //Iterableなのでこういった使い方が可能(ちなみにリストでも可能)
  for(var m in l2){
    print(m);   //3、6、9が表示される
  }
}

「where」は英語の「ここで~とする」という理系分野でよく用いられる表現のwhereです。

map」は全ての要素を回って、与えられたコールバック関数を適用し、各要素の値を変換した形のイテレータを返します。ここでコールバック関数の型はListに対してT2 Function(T)型、作られるイテレータはIterableです。

void main(){
  var list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
  
  //全て文字列C+番号に変換していきます。最後にtoListでそれらをリストにします。
  var l1 = list.map((int n)=>"C"+n.toString()).toList();

 print(l1);   //[C1, C2, C3, C4, C5, C6, C7, C8, C9, C10]
}

skip」は与えられた要素数をスキップします。3を与えたら前から3番目までをスキップします。

followedBy」はIterableを引数にとって、後ろに追加する形になります。addAllと同じ挙動になります。

これらはIterableを返しますので、チェーン状に連続してつないでいけます。

void main(){
  var list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
  var other = [11, 12, 13];
  
  //3つスキップ
  var l1 = list.skip(3).toList();

  //otherを加える
  var l2 = list.followedBy(other).toList();

  //カスケードでつなぎます
  var l3 = list
    .where((int n)=>n%2==0)
    .skip(2)
    .followedBy(other)
    .map((int n)=>"C"+n.toString())
    .toList();
    
  print(l1);   //[4, 5, 6, 7, 8, 9, 10]
  print(l2);   //[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
  print(l3);   //[C6, C8, C10, C11, C12, C13]
}

every」はすべての要素について与えられたコールバック関数が真を返すかどうかをチェックし、全て真ならば真、そうでなければ偽を返します。

contains」は与えられた要素が含まれているかを判定し、含まれていれば真を返します。

reduce」は二つの引数のコールバック関数をとって、1つずつ要素をずらしながら関数を適用していき、最終的な関数の返り値を返します。

fold」はreduceと似た動きをしますが、初期値を設定できることと、必ずしもコレクションの要素と同じ型でなくてもよいという点が違います。

void main(){
  var list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
  
  var e1 = list.skip(3).every((int n)=>n>3);
  var e2 = list.skip(3).every((int n)=>n>4);
  
  var c1 = list.skip(3).contains(3);
  var c2 = list.skip(3).contains(4);
  
  var r1 = list.reduce((int a, int b)=>a+b);
  var f1 = list.fold("Start", (String x, int a)=>x+" C"+a.toString());
    
  print(e1);   //true
  print(e2);   //false
  print(c1);   //false
  print(c2);   //true
  print(r1);   //55
  print(f1);   //Start C1 C2 C3 C4 C5 C6 C7 C8 C9 C10
}

このあたりは多くの例をみたり、自分であれこれ試してみたりして慣れるのが一番です。

データの集まりがあったときに、データベース的にクエリ(条件)を絞ってデータを取り出す必要があるときにこれらのメソッドは非常に便利に使えます。通常のif文やfor文を積み重ねて書くことも可能ですが、それを一行の式で表せてしまいますからね。





おわりに

昔からソフトウェアはデータ構造+アルゴリズムであるとよく言います。

通常の多くのアプリケーションはそこまで凝ったデータ構造とアルゴリズムを考える必要はありませんので、一番肝になるのはこのコレクションフレームワークを適切に扱えるかどうか、というところでしょう。

上手い下手に違いがはっきり出てくるところではありますが、下手だから致命的に困るかと言うと、よほどの数のデータを扱わない限り、結果に違いがでないことがほとんどです。

徐々に自分の使える技を増やしていくのが良いと思います。

最終的に上級者になるためには「計算量」の概念、ハッシュテーブル、二分木、各コレクションの実装あたりを踏まえていく必要があります。ステップアップの余裕がある方は、このあたりを調べてみて下さい。全部良く知っているならFlutterの範囲内のアプリ開発では十分以上でしょう。


f:id:linkedsort:20211113114816j:plain