Dart Object-Oriented Programming

Collections & Iterables in OOP

50 min Lesson 22 of 24

Understanding Iterables in Dart

At the heart of Dart’s collection system lies the Iterable<E> interface. Every List, Set, and Map.values implements Iterable. When you use a for-in loop, you’re consuming an Iterable. Understanding this interface lets you create custom collections that integrate seamlessly with Dart’s language features -- for-in loops, map, where, fold, and more.

In this lesson, you’ll implement your own iterators, build generic custom collections like Stack and Queue, create paginated data sources, and learn about unmodifiable collections for safe API design.

The Iterable and Iterator Interfaces

Dart’s iteration system uses two cooperating interfaces:

  • Iterable<E> -- An object that can produce an Iterator. It has a single required property: Iterator<E> get iterator.
  • Iterator<E> -- An object that walks through elements one by one. It has two members: bool moveNext() and E get current.

How Iterable and Iterator Work

void main() {
  List<String> fruits = ['apple', 'banana', 'cherry'];

  // Behind the scenes, for-in does this:
  Iterator<String> it = fruits.iterator;
  while (it.moveNext()) {
    print(it.current);
  }

  // Which is equivalent to:
  for (String fruit in fruits) {
    print(fruit);
  }

  // Iterable methods work on any Iterable
  Iterable<String> upperFruits = fruits.map((f) => f.toUpperCase());
  Iterable<String> longFruits = fruits.where((f) => f.length > 5);

  print(upperFruits.toList());  // [APPLE, BANANA, CHERRY]
  print(longFruits.toList());   // [banana, cherry]
}
Key Concept: Iterables are lazy by default. When you call .map() or .where(), no computation happens immediately. The transformation is applied only when you consume the iterable (e.g., with toList(), for-in, or first). This is important for performance with large datasets.

Implementing a Custom Iterator

To make any class iterable, you implement Iterable<E> and provide a custom Iterator<E>. Let’s start with a simple range iterator that generates numbers.

Custom Range Iterator

class NumberRange extends Iterable<int> {
  final int start;
  final int end;
  final int step;

  const NumberRange(this.start, this.end, {this.step = 1});

  @override
  Iterator<int> get iterator => _NumberRangeIterator(start, end, step);
}

class _NumberRangeIterator implements Iterator<int> {
  final int _end;
  final int _step;
  int _current;
  bool _started = false;

  _NumberRangeIterator(int start, this._end, this._step)
      : _current = start - _step;

  @override
  int get current => _current;

  @override
  bool moveNext() {
    if (!_started) {
      _current += _step;
      _started = true;
    } else {
      _current += _step;
    }
    return _current <= _end;
  }
}

void main() {
  // Works with for-in
  for (int n in NumberRange(1, 10, step: 2)) {
    print(n);  // 1, 3, 5, 7, 9
  }

  // Works with all Iterable methods
  final range = NumberRange(1, 20);
  final evenSquares = range
      .where((n) => n.isEven)
      .map((n) => n * n)
      .toList();
  print(evenSquares);  // [4, 16, 36, 64, 100, 144, 196, 256, 324, 400]

  // Lazy -- does not generate all numbers
  print(NumberRange(1, 1000000).first);   // 1 (instant)
  print(NumberRange(1, 1000000).take(5).toList());  // [1, 2, 3, 4, 5]
}

Building a Generic Stack

A Stack is a Last-In-First-Out (LIFO) data structure. By making it generic and iterable, it integrates naturally with Dart’s collection ecosystem.

Generic Stack Collection

class Stack<E> extends Iterable<E> {
  final List<E> _items = [];

  // Push an item onto the top
  void push(E item) => _items.add(item);

  // Pop the top item (throws if empty)
  E pop() {
    if (_items.isEmpty) {
      throw StateError('Cannot pop from an empty stack');
    }
    return _items.removeLast();
  }

  // Peek at the top without removing
  E get peek {
    if (_items.isEmpty) {
      throw StateError('Cannot peek at an empty stack');
    }
    return _items.last;
  }

  // Clear all items
  void clear() => _items.clear();

  @override
  bool get isEmpty => _items.isEmpty;

  @override
  bool get isNotEmpty => _items.isNotEmpty;

  @override
  int get length => _items.length;

  // Iterate from top to bottom (reverse order)
  @override
  Iterator<E> get iterator => _items.reversed.iterator;

  @override
  String toString() => 'Stack(top->bottom): ${_items.reversed.toList()}';
}

void main() {
  final stack = Stack<int>();
  stack.push(10);
  stack.push(20);
  stack.push(30);

  print(stack);           // Stack(top->bottom): [30, 20, 10]
  print(stack.peek);      // 30
  print(stack.pop());     // 30
  print(stack.length);    // 2

  // Works with for-in (iterates top to bottom)
  for (int item in stack) {
    print(item);  // 20, then 10
  }

  // Works with Iterable methods
  print(stack.where((n) => n > 15).toList());  // [20]
  print(stack.contains(10));  // true
}

Building a Generic Queue

A Queue is a First-In-First-Out (FIFO) data structure -- perfect for task scheduling, message processing, and breadth-first traversals.

Generic Queue Collection

class SimpleQueue<E> extends Iterable<E> {
  final List<E> _items = [];

  // Add to the back
  void enqueue(E item) => _items.add(item);

  // Add multiple items
  void enqueueAll(Iterable<E> items) => _items.addAll(items);

  // Remove from the front
  E dequeue() {
    if (_items.isEmpty) {
      throw StateError('Cannot dequeue from an empty queue');
    }
    return _items.removeAt(0);
  }

  // Peek at the front
  E get front {
    if (_items.isEmpty) {
      throw StateError('Queue is empty');
    }
    return _items.first;
  }

  void clear() => _items.clear();

  @override
  bool get isEmpty => _items.isEmpty;

  @override
  int get length => _items.length;

  @override
  Iterator<E> get iterator => _items.iterator;

  @override
  String toString() => 'Queue(front->back): $_items';
}

// Priority Queue using Comparable
class PriorityQueue<E extends Comparable<E>> extends Iterable<E> {
  final List<E> _items = [];

  void enqueue(E item) {
    _items.add(item);
    _items.sort();  // Keep sorted by natural order
  }

  E dequeue() {
    if (_items.isEmpty) {
      throw StateError('Priority queue is empty');
    }
    return _items.removeAt(0);  // Remove highest priority (smallest)
  }

  E get front => _items.first;

  @override
  bool get isEmpty => _items.isEmpty;

  @override
  int get length => _items.length;

  @override
  Iterator<E> get iterator => _items.iterator;
}

void main() {
  // Simple queue
  final queue = SimpleQueue<String>();
  queue.enqueue('Task A');
  queue.enqueue('Task B');
  queue.enqueue('Task C');
  print(queue.dequeue());  // Task A (first in, first out)

  // Priority queue
  final pq = PriorityQueue<int>();
  pq.enqueue(30);
  pq.enqueue(10);
  pq.enqueue(20);
  print(pq.dequeue());  // 10 (smallest first)
  print(pq.dequeue());  // 20
}
Tip: Notice how PriorityQueue<E extends Comparable<E>> uses a bounded type parameter. This guarantees at compile time that only comparable types can be used, so sorting always works.

Paginated Collection

A common real-world need is paginating through large datasets. Let’s build a lazy paginated collection that loads pages on demand.

Paginated Data Source

class Page<T> {
  final List<T> items;
  final int pageNumber;
  final int totalPages;
  final int totalItems;

  const Page({
    required this.items,
    required this.pageNumber,
    required this.totalPages,
    required this.totalItems,
  });

  bool get hasNext => pageNumber < totalPages;
  bool get hasPrevious => pageNumber > 1;
}

// Abstract paginated data source
abstract class PaginatedSource<T> extends Iterable<T> {
  final int pageSize;

  PaginatedSource({this.pageSize = 10});

  // Subclasses implement this to fetch a page
  Page<T> fetchPage(int pageNumber);

  @override
  Iterator<T> get iterator => _PaginatedIterator(this);
}

class _PaginatedIterator<T> implements Iterator<T> {
  final PaginatedSource<T> _source;
  Page<T>? _currentPage;
  int _indexInPage = -1;
  bool _done = false;

  _PaginatedIterator(this._source);

  @override
  T get current => _currentPage!.items[_indexInPage];

  @override
  bool moveNext() {
    if (_done) return false;

    // Load first page
    if (_currentPage == null) {
      _currentPage = _source.fetchPage(1);
      if (_currentPage!.items.isEmpty) {
        _done = true;
        return false;
      }
      _indexInPage = 0;
      return true;
    }

    // Move within current page
    _indexInPage++;
    if (_indexInPage < _currentPage!.items.length) {
      return true;
    }

    // Load next page
    if (_currentPage!.hasNext) {
      _currentPage = _source.fetchPage(_currentPage!.pageNumber + 1);
      _indexInPage = 0;
      return _currentPage!.items.isNotEmpty;
    }

    _done = true;
    return false;
  }
}

// Concrete implementation
class UserPaginatedSource extends PaginatedSource<String> {
  final List<String> _allUsers;

  UserPaginatedSource(this._allUsers, {super.pageSize});

  @override
  Page<String> fetchPage(int pageNumber) {
    final start = (pageNumber - 1) * pageSize;
    final end = start + pageSize;
    final items = _allUsers.sublist(
      start.clamp(0, _allUsers.length),
      end.clamp(0, _allUsers.length),
    );
    final totalPages = (_allUsers.length / pageSize).ceil();

    print('  [Loading page $pageNumber of $totalPages]');

    return Page(
      items: items,
      pageNumber: pageNumber,
      totalPages: totalPages,
      totalItems: _allUsers.length,
    );
  }
}

void main() {
  final allUsers = List.generate(25, (i) => 'User_${i + 1}');
  final source = UserPaginatedSource(allUsers, pageSize: 10);

  // Lazy -- only loads pages as needed
  print('First 5 users:');
  for (String user in source.take(5)) {
    print('  $user');
  }
  // Output: [Loading page 1 of 3], then User_1 through User_5

  print('\nAll users (loads all pages):');
  print('  Total: ${source.length}');
  // Loads all 3 pages to count
}

Unmodifiable Collections

When exposing collections through an API, you often want to prevent external code from modifying internal state. Dart provides UnmodifiableListView, UnmodifiableMapView, and you can build your own unmodifiable wrappers.

Unmodifiable Collections for Safe APIs

import 'dart:collection';

class StudentRegistry {
  final List<String> _students = [];

  // Return an unmodifiable view -- callers cannot add/remove
  UnmodifiableListView<String> get students =>
      UnmodifiableListView(_students);

  void addStudent(String name) {
    if (name.trim().isEmpty) {
      throw ArgumentError('Student name cannot be empty');
    }
    _students.add(name);
  }

  bool removeStudent(String name) => _students.remove(name);
}

// Custom unmodifiable collection
class ReadOnlyMap<K, V> {
  final Map<K, V> _data;

  const ReadOnlyMap(this._data);

  V? operator [](K key) => _data[key];
  bool containsKey(K key) => _data.containsKey(key);
  int get length => _data.length;
  Iterable<K> get keys => _data.keys;
  Iterable<V> get values => _data.values;

  // No add, remove, or update methods!
}

void main() {
  final registry = StudentRegistry();
  registry.addStudent('Alice');
  registry.addStudent('Bob');

  // Can read the list
  final students = registry.students;
  print(students);  // [Alice, Bob]

  // Cannot modify -- throws UnsupportedError
  try {
    students.add('Charlie');
  } on UnsupportedError {
    print('Cannot modify unmodifiable list!');
  }

  // ReadOnlyMap
  final config = ReadOnlyMap({'host': 'localhost', 'port': '8080'});
  print(config['host']);  // localhost
  // config['host'] = 'x';  // Compile error -- no []= operator
}
Warning: Returning List.unmodifiable() creates a new list (a copy), while UnmodifiableListView() wraps the original without copying. If the original changes, the view reflects those changes. Choose based on whether you want a snapshot or a live read-only view.

Practical Example: Filtered Observable Collection

Let’s build a collection that combines several concepts: generics, iteration, filtering, and the observer pattern to notify listeners when items change.

Observable Filtered Collection

typedef CollectionCallback<E> = void Function(E item, String action);

class ObservableList<E> extends Iterable<E> {
  final List<E> _items = [];
  final List<CollectionCallback<E>> _listeners = [];

  // Subscribe to changes
  void addListener(CollectionCallback<E> callback) {
    _listeners.add(callback);
  }

  void removeListener(CollectionCallback<E> callback) {
    _listeners.remove(callback);
  }

  void _notify(E item, String action) {
    for (var listener in _listeners) {
      listener(item, action);
    }
  }

  // Mutating operations notify listeners
  void add(E item) {
    _items.add(item);
    _notify(item, 'added');
  }

  bool remove(E item) {
    final removed = _items.remove(item);
    if (removed) _notify(item, 'removed');
    return removed;
  }

  // Create a filtered view
  FilteredView<E> where_(bool Function(E) test) {
    return FilteredView(this, test);
  }

  @override
  Iterator<E> get iterator => _items.iterator;

  @override
  int get length => _items.length;
}

class FilteredView<E> extends Iterable<E> {
  final ObservableList<E> _source;
  final bool Function(E) _test;

  FilteredView(this._source, this._test);

  // Always reflects current state of source
  @override
  Iterator<E> get iterator =>
      _source._items.where(_test).iterator;
}

class Task {
  final String title;
  final bool completed;
  final String priority;

  const Task(this.title, {this.completed = false, this.priority = 'medium'});

  @override
  String toString() => '$title [${completed ? "done" : priority}]';
}

void main() {
  final tasks = ObservableList<Task>();

  // Listen for changes
  tasks.addListener((task, action) {
    print('  Event: "$action" - ${task.title}');
  });

  // Create filtered views
  final activeTasks = tasks.where_((t) => !t.completed);
  final highPriority = tasks.where_((t) => t.priority == 'high');

  tasks.add(Task('Write docs', priority: 'high'));
  tasks.add(Task('Fix bug', priority: 'high'));
  tasks.add(Task('Code review', priority: 'low'));
  tasks.add(Task('Deploy', completed: true));

  print('\nActive tasks: ${activeTasks.length}');   // 3
  print('High priority: ${highPriority.length}');  // 2

  // Views update automatically
  tasks.remove(Task('Fix bug', priority: 'high'));
  print('High priority after removal: ${highPriority.length}');
}
Best Practice: When building custom collections, always extend Iterable<E> (or implement it) so your collection works naturally with Dart’s for-in loops, spread operators, and all the built-in collection methods like map, where, fold, any, and every.