Dart Tip: Default Constructor Parameters With Collections

In dart one can have optional named parameters instead of positional parameters. This not only allows one to allow named arguments when calling methods but also default values:

void writeCount({int count = 5, String prefix = ''}) {
  List.generate(count, (i) => i + 1).forEach((e) => print('$prefix$e'));
}

void main(List<String> arguments) {
  writeCount();
  writeCount(count: 3);
  writeCount(count: 3, prefix: 'Last one: ');
}

This can be very beneficial in constructors as well. One of the limitations of the defaults is that it has to be a compile-time constant value. That means the default can’t be calling into some other function/method. It also means that collection values must be constant as well. So for example valid syntax like above in a constructor would be:

class ShoppingCart {
  final List<String> items;
  
  ShoppingCart({this.items = const []});
}

A user constructing a ShoppingCart could therefore either do:

final cart = ShoppingCart();

or

final cart = ShoppingCart({'item1', 'item2'});

However there is a potential problem with our default. The const [] code isn’t just creating an empty List. It is creating an unmodifiable list. That means that any attempt to add/remove items from the list will throw a runtime exception. For example the below code throws an UnsupportedError exception:

final cart = ShoppingCart();
cart.items.add('New Item');

If the entire class is supposed to be unmodifiable then that is fine. If however the attempt was to add a default empty list then we have to get a bit more creative:

class ShoppingCart {
  final List<String> items;

  ShoppingCart({List<String>? initialItems}):
        items = initialItems ?? [];

}

We are leveraging a few things here. First, nullable items in the named types list don’t have to be given a value. We signify the nullability by adding the ? to the end of the type for the item. Second, Dart has the ability to have a default value assignment if a value is null by using the ?? operator. Third, while the default values within the parameter list have to be constant, in the body of the constructor there is no such requirement.

Note #1

For named parameter lists the item either has to be nullable, required, or given a default value:

// Won't compile
void function1({String arg}){}

// Compiles because is nullable
void function2({String? arg}){}

// Compiles because it is required
void function3({required String arg}){}

// Compiles because has a default
void function4({String arg=''}){}

Note #2

It is often very bad form to have directly modifiable collections exposed outside the class like this. For cases where you want to expose the collection in a read-only manner while still maintaining the ability to modify the data internal to the class one can expose a public getter that returns an unmodifiable collection view into the internal collection:

class ShoppingCart {
  final List<String> _items;
  List<String> get items => List.unmodifiable(_items);

  ShoppingCart(): _items = [];

  void addItem(String item) {
    _items.add(item);
  }

}

void main(List<String> arguments) {
  final cart = ShoppingCart();

  // works
  cart.addItem('New Item 1');

  // throws exception:
  try {
    cart.items.add('New Item 2');
  } catch(e){
    print(e);
  }

  cart.items.forEach((e) => print(e));
}

This is creating another data structure though, not returning a view into the existing one so for very large collections there will be an overhead. In testing the overhead time was on the order of:

  • 0.3 ms for 10,000
  • 1 ms for 100,000
  • 7 ms for 1,000,000
  • 80 ms for 10,000,000
  • 800 ms for 100,000,000