Classes, Constructors and Value Objects

Article · 13:00

Dart class syntax is compact in one specific way: the constructor can assign its own fields, so Point(this.x, this.y) is the whole thing - no body, no repetition. With named and factory constructors, and the equality contract a value object has to sign.

Classes, Constructors and Value Objects

Dart's class syntax is compact in one specific way: the constructor can assign its own fields. Point(this.x, this.y) is the whole thing - no body, no repetition.

class Point {
  final double x;
  final double y;

  Point(this.x, this.y);

  double get magnitude => (x * x + y * y);

  @override
  String toString() => 'Point($x, $y)';
}

void main() {
  final p = Point(3, 4);
  print(p);
  print(p.magnitude);
  // p.x = 9;          // final fields: this would not compile
}

new is optional and nobody writes it. A getter declared with get is called without parentheses, which is the convention for anything that only reads.

Named constructors, initialiser lists and validation

A class can have as many constructors as it has meanings, each with a name. An initialiser list (after the :) runs before the body and is where final fields and validation go.

class Temperature {
  final double celsius;

  // The main constructor validates in the initialiser list, because assert and
  // field initialisers both run before the body.
  Temperature(this.celsius) : assert(celsius >= -273.15, 'below absolute zero');

  // Named constructors read at the call site: Temperature.fahrenheit(98.6).
  Temperature.fahrenheit(double f) : celsius = (f - 32) * 5 / 9;
  Temperature.absoluteZero() : celsius = -273.15;

  double get fahrenheit => celsius * 9 / 5 + 32;

  @override
  String toString() => '${celsius.toStringAsFixed(1)}C';
}

void main() {
  print(Temperature(21));
  print(Temperature.fahrenheit(98.6));
  print(Temperature.absoluteZero().fahrenheit);
}

Factory constructors

A factory does not have to create a new instance. It can return a cached one, an instance of a subclass, or throw - which makes it the natural home for parsing.

class Currency {
  final String code;

  // A private constructor, so the only way in is through the factory.
  const Currency._(this.code);

  static final Map<String, Currency> _cache = {};

  factory Currency(String code) {
    final upper = code.toUpperCase();
    if (upper.length != 3) {
      throw FormatException('a currency code is three letters', code);
    }
    return _cache.putIfAbsent(upper, () => Currency._(upper));
  }

  @override
  String toString() => code;
}

void main() {
  final a = Currency('gbp');
  final b = Currency('GBP');
  print(identical(a, b));        // true: the factory returned the cached one

  try {
    Currency('pounds');
  } on FormatException catch (e) {
    print(e.message);
  }
}

Equality is identity until you say otherwise

Two separately built objects with the same contents are not equal in Dart unless the class says how to compare them. A value object overrides both == and hashCode - both, always, because collections use the pair.

class Money {
  final int pence;
  final String currency;

  const Money(this.pence, this.currency);

  @override
  bool operator ==(Object other) =>
      other is Money && other.pence == pence && other.currency == currency;

  @override
  int get hashCode => Object.hash(pence, currency);

  Money operator +(Money other) {
    if (other.currency != currency) {
      throw ArgumentError('cannot add $currency to ${other.currency}');
    }
    return Money(pence + other.pence, currency);
  }

  @override
  String toString() => '${(pence / 100).toStringAsFixed(2)} $currency';
}

void main() {
  const a = Money(500, 'GBP');
  const b = Money(500, 'GBP');

  print(a == b);                       // true, because == was overridden
  print([a, b].toSet().length);        // 1, because hashCode agrees with ==
  print(a + const Money(250, 'GBP'));

  // const constructors also mean the two literals are one object.
  print(identical(a, b));
}

If you override == and forget hashCode, sets and map keys quietly stop working: two equal objects land in different buckets and both survive.

Inheritance, and preferring composition to it

abstract class Shape {
  const Shape();

  double get area;                       // no body: subclasses must supply one

  String describe() => '${runtimeType} of area ${area.toStringAsFixed(2)}';
}

class Rectangle extends Shape {
  final double width;
  final double height;

  const Rectangle(this.width, this.height);

  @override
  double get area => width * height;
}

class Circle extends Shape {
  final double radius;

  const Circle(this.radius);

  @override
  double get area => 3.141592653589793 * radius * radius;

  @override
  String describe() => 'Circle r=$radius, ${super.describe()}';
}

void main() {
  const shapes = <Shape>[Rectangle(3, 4), Circle(1)];
  for (final shape in shapes) {
    print(shape.describe());
  }
}

abstract means the class cannot be instantiated and may leave members without bodies. @override is not required by the compiler, but the analyzer will tell you when it is wrong - which is the case where you renamed a method and left a near-miss behind.

Static members and constants

class Config {
  static const int maxRetries = 3;
  static int _instances = 0;

  final String name;

  Config(this.name) {
    _instances++;
  }

  static int get instances => _instances;
}

void main() {
  Config('a');
  Config('b');
  print(Config.maxRetries);
  print(Config.instances);
}

What to take away

  • this.field in a constructor assigns it; the initialiser list runs first.
  • Named constructors give each way of building the object a readable name.
  • factory can cache, return a subclass, or throw - it need not build anything.
  • Override == and hashCode together, or sets and maps misbehave.
  • A const constructor lets identical literals share one object.
  • abstract for a contract with a partial implementation; @override to catch typos.