skip to content

In Dart, when a Cart class exposes its items read-only, how does returning List.unmodifiable(_items) differ from returning UnmodifiableListView(_items)?

level: middleimportance: must knowfreq 50%

answer

  1. snapshot versus window
  2. copy is O(n) per call
  3. view is O(1), reads through
  4. view shows later cart changes
  5. both shallow: items still mutable

basics

~20 s

List.unmodifiable copies the items into a new frozen list, so each call costs O(n) and callers hold a snapshot; UnmodifiableListView wraps the private list in O(1), so callers see later additions. Both block list writes, and neither freezes the items themselves.

solid answer

~40 s

Both stop callers from calling `add`, `remove` or `[]=` on what they receive, but they differ in **when the data is read**. `List.unmodifiable(_items)` copies every element into a new unmodifiable list: O(n) per call, and the caller's result is a **snapshot** that never changes, even when the cart does. `UnmodifiableListView(_items)` from `dart:collection` is a thin wrapper: O(1), no copy, and every read goes to the live `_items`, so a caller that kept the view sees items added later. Choose the view for a cheap live read-only window on internal state, the copy when callers need a stable value, for example to compare an old and new state. Neither protects the `CartItem` objects: if they have mutable fields, callers can still change them.

code

dart · 24 lines
dart
import 'dart:collection';

class CartItem {
  CartItem(this.name);
  final String name;
}

class Cart {
  final List<CartItem> _items = [];

  List<CartItem> get snapshot => List.unmodifiable(_items);
  late final UnmodifiableListView<CartItem> items = UnmodifiableListView(_items);

  void add(CartItem item) => _items.add(item);
}

void main() {
  final cart = Cart()..add(CartItem('tea'));
  final before = cart.snapshot;
  final live = cart.items;
  cart.add(CartItem('milk'));
  print(before.length); // 1: the copy is a snapshot
  print(live.length); // 2: the view reads through
}

go deeper

for a junior

Know that both options stop add and remove, and that one copies while the other wraps the original list.

for a middle

Explain snapshot versus live view with their O(n) and O(1) costs, and show why both are shallow for mutable CartItem objects.

for a senior

Choose per call site: views for cheap getters on mutable models, copies for state comparison and iteration during changes; cache hot views.

for a principal

Decide whether models should be mutable with views or immutable with copy-on-write, weighing allocation cost against the class of bugs each design rules out.

## The setup A shopping cart keeps its items in a private growable list and exposes them read-only: ```dart import 'dart:collection'; class Cart { final List<CartItem> _items = []; // Option A: a copy List<CartItem> get itemsCopy => List.unmodifiable(_items); // Option B: a view UnmodifiableListView<CartItem> get itemsView => UnmodifiableListView(_items); void add(CartItem item) => _items.add(item); } ``` Both getters prevent `cart.itemsCopy.add(...)` and `cart.itemsView.add(...)`: each throws `UnsupportedError`. The differences are in **cost**, **freshness** and **identity**. ## What each one does **`List.unmodifiable(_items)`** creates a new list, copies every element reference into it, and marks it unmodifiable. - Cost: O(n) time and memory on **every** call to the getter. - Freshness: a **snapshot**. Items added to the cart afterwards do not appear in it. - Independence: later changes to `_items` cannot affect it. **`UnmodifiableListView(_items)`** creates a small wrapper object that forwards `length` and `[]` to `_items` and throws on writes. - Cost: O(1); nothing is copied. - Freshness: **live**. A caller that stored the view sees items added later. - Coupling: the view is only as stable as the list behind it. | Aspect | `List.unmodifiable` | `UnmodifiableListView` | |---|---|---| | Copies elements | yes, O(n) | no, O(1) | | Sees later cart changes | no | yes | | Blocks list writes | yes | yes | | Protects element objects | no | no | | Library | `dart:core` | `dart:collection` | ## Live views have a sharp edge Because a view reads through, iterating it while the cart changes behaves exactly like iterating `_items` while it changes: adding to or removing from a list during a `for`-in loop over it throws a `ConcurrentModificationError`. If callers may iterate while something else calls `cart.add`, give them a copy. A view can also surprise code that compares states. If a caller stores `previous = cart.itemsView` and later compares it with the current contents, both read the same live list, so "previous" has silently become current. A snapshot copy is what makes before-and-after comparisons meaningful. ## Neither one freezes the items Both options are **shallow**. They freeze the list's length and slots, not the objects in the slots. If `CartItem` has a mutable `int quantity`, then `cart.itemsView.first.quantity = 99` succeeds with either option. To protect the items too: 1. make `CartItem` immutable, with `final` fields and a `copyWith` that returns a new item; 2. change quantities through `Cart` methods that replace the item in `_items`. ## Why not just return `Iterable<CartItem>`? A common shortcut is `Iterable<CartItem> get items => _items;`. It hides `add` from the analyzer, but the object handed out is still the private growable list: - `(cart.items as List<CartItem>).add(x)` compiles and succeeds, mutating the cart's internals; - a caller can keep the reference and see later changes, like a view, but without any write protection; - callers who need indexing must call `toList()`, paying for a copy anyway. Returning an unmodifiable object is what actually enforces read-only access. A narrower return type can sit on top of it as documentation, but it is not a substitute. ## Choosing - **Default to the view** for a getter on a mutable model: cheap, read-only, and always current. - **Use a copy** when the caller needs a stable value: an old state to compare against, or a list to iterate while the cart may change. - **Cache the view** if the getter is hot: `late final UnmodifiableListView<CartItem> items = UnmodifiableListView(_items);` creates one wrapper that stays valid for the cart's lifetime. - In Flutter, a `ChangeNotifier` cart that exposes a view and calls `notifyListeners()` after each change is a common, correct shape (the Flutter docs' simple state-management `CartModel` does exactly this): widgets rebuild and read the current items through the view.

  • How would you stop callers of a Dart Cart from changing an item's quantity, not just the list?
    Make `CartItem` immutable, with `final` fields and a `copyWith`, and route quantity changes through a `Cart` method that replaces the item in the private list. An unmodifiable list or view only freezes the slots; it cannot stop writes to mutable objects inside them.
  • Why might a Dart cart getter cache its UnmodifiableListView instead of creating one per call?
    Creating a view is cheap but still allocates a wrapper on every access. Because the view reads through to the private list, one instance stays correct for the object's lifetime, so `late final items = UnmodifiableListView(_items)` gives callers the same object each time and avoids repeated allocations.

saying these in an interview costs you the question

  • UnmodifiableListView copies the list so later changes are not visible
  • List.unmodifiable is as cheap as a view because it is lazy
  • Exposing items read-only also stops callers changing an item's fields
  • A view is always safer than a copy for comparing old and new state
  • UnmodifiableListView lives in dart:core alongside List