Lists, Maps, Sets and Iterable

Article · 12:00

Three collections carry almost every Dart program: List for order, Set for membership, Map for lookup. All three are generic, so the element type travels with the collection - and Iterable is lazy, which is the surprise worth meeting here rather than in production.

Lists, Maps, Sets and Iterable

Three collections carry almost every Dart program: List for order, Set for membership, Map for lookup. All three are generic, so the element type travels with the collection and the compiler can check what you take out of it.

void main() {
  final numbers = <int>[3, 1, 2];          // List<int>
  final unique = ['a', 'b', 'a'].toSet();  // Set<String>, so two entries
  final ages = <String, int>{'ada': 36, 'grace': 45};  // Map<String, int>

  print(numbers);
  print(unique);
  print(ages);

  // The literal types are inferred when you give it something to work with.
  final inferred = [1, 2, 3];
  print(inferred.runtimeType);

  // An empty literal with no type argument is a Map, not a Set.
  print(<String>{}.runtimeType);
  print({}.runtimeType);
}

Lists

void main() {
  final tasks = ['write', 'review'];

  tasks.add('publish');
  tasks.insert(0, 'plan');
  tasks.remove('review');

  print(tasks);
  print(tasks.first);
  print(tasks.last);
  print(tasks.length);
  print(tasks.indexOf('publish'));
  print(tasks.contains('plan'));

  // Slicing gives a new list; sublist's end is exclusive.
  print(tasks.sublist(1, 3));

  // sort mutates in place and returns nothing - a common surprise.
  final scores = [88, 42, 95];
  scores.sort();
  print(scores);
  scores.sort((a, b) => b.compareTo(a));   // descending
  print(scores);

  // A fixed-length list refuses to grow, which is occasionally what you want.
  final fixed = List<int>.filled(3, 0);
  try {
    fixed.add(1);
  } on UnsupportedError {
    print('fixed-length lists do not grow');
  }
}

Maps

void main() {
  final stock = <String, int>{'apples': 3, 'pears': 0};

  // Reading a key that is not there gives null, not an error - which is why
  // the return type is int? and not int.
  print(stock['apples']);
  print(stock['plums']);
  print(stock['plums'] ?? 0);

  stock['plums'] = 7;                  // insert or overwrite
  stock.putIfAbsent('apples', () => 99);   // only if missing, so no change
  print(stock);

  // update is the one that edits an existing value safely.
  stock.update('apples', (current) => current + 1, ifAbsent: () => 1);
  print(stock['apples']);

  print(stock.containsKey('pears'));
  print(stock.keys.toList());
  print(stock.values.toList());

  for (final entry in stock.entries) {
    print('${entry.key}: ${entry.value}');
  }

  // Insertion order is preserved: the default Map is a LinkedHashMap.
  print(stock.keys.join(' -> '));
}

Building collections with if and for

Collection-if and collection-for let a literal describe a shape that would otherwise need a mutable variable and several statements.

void main() {
  const isAdmin = true;
  const extras = ['metrics', 'flags'];

  final menu = [
    'home',
    'courses',
    if (isAdmin) 'admin',          // included only when the condition holds
    for (final extra in extras) 'tools/$extra',
    ...['help', 'about'],          // spread
  ];

  print(menu);

  // ...? spreads a list that may be null.
  List<String>? maybeMore;
  final combined = [...menu, ...?maybeMore];
  print(combined.length);

  // The same syntax builds maps and sets.
  final flags = {
    for (final extra in extras) extra: true,
  };
  print(flags);
}

Iterable is the vocabulary, and it is lazy

map, where, expand and friends are defined on Iterable, which is the supertype of every collection. They do not do the work when you call them: they describe it, and the work happens when something asks for elements.

void main() {
  final numbers = [1, 2, 3, 4, 5, 6];

  final result = numbers
      .where((n) => n.isEven)
      .map((n) => n * n)
      .toList();
  print(result);

  print(numbers.fold<int>(0, (sum, n) => sum + n));
  print(numbers.reduce((a, b) => a > b ? a : b));
  print(numbers.any((n) => n > 5));
  print(numbers.every((n) => n > 0));
  print(numbers.take(2).toList());
  print(numbers.skip(4).toList());
  print(numbers.expand((n) => [n, -n]).take(4).toList());

  // firstWhere throws when nothing matches, unless you say what to do instead.
  print(numbers.firstWhere((n) => n > 4, orElse: () => -1));
  print(numbers.firstWhere((n) => n > 99, orElse: () => -1));
}

Laziness is worth seeing, because it decides when your side effects happen.

void main() {
  final source = [1, 2, 3];

  final lazy = source.map((n) {
    print('  mapping $n');
    return n * 2;
  });

  print('nothing has run yet');
  print('first: ${lazy.first}');       // maps 1 only
  print('now the whole thing:');
  print(lazy.toList());                // maps 1, 2 and 3

  // And it re-runs every time you iterate, so cache with toList() when the
  // work is expensive or the source can change.
  print(lazy.length);
}

What to take away

  • List, Set and Map are generic; the type argument is checked for you.
  • map[key] returns V? - a missing key is null, not an error.
  • sort mutates and returns nothing; sublist and where return something new.
  • Collection-if, collection-for and ... build a literal in one expression.
  • Iterable methods are lazy: they run when iterated, and again on each pass.
  • toList() is how you pay for the work once.