Collections & Iterables in OOP
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 anIterator. 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()andE 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]
}
.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
}
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
}
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}');
}
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.