Dart Object-Oriented Programming

Operator Overloading

45 min Lesson 13 of 24

What Is Operator Overloading?

Operator overloading allows you to define custom behavior for standard operators like +, -, *, ==, <, >, [], and []= when they are used with your own classes. Instead of calling a method like vector.add(other), you can write the much more natural vector + other.

In Dart, operators are actually methods with special names. When you write a + b, Dart calls a.+(b) under the hood. By overriding these special methods in your class, you control what happens when an operator is used with your objects.

Key Concept: Operator overloading does not create new operators -- it redefines the behavior of existing Dart operators for your custom types. This makes your code more expressive and intuitive.

The operator Keyword

To overload an operator, you define a method using the operator keyword followed by the operator symbol. The method takes the right-hand operand as its parameter (for binary operators) or no parameters (for unary operators like - used as negation).

Basic Syntax

class MyClass {
  // Binary operator: a + b
  MyClass operator +(MyClass other) {
    // return a new MyClass combining this and other
  }

  // Unary operator: -a
  MyClass operator -() {
    // return a negated version
  }

  // Comparison operator: a == b
  @override
  bool operator ==(Object other) {
    // return true if equal
  }

  // Index operator: a[index]
  int operator [](int index) {
    // return element at index
  }

  // Index assignment: a[index] = value
  void operator []=(int index, int value) {
    // set element at index
  }
}

Overridable Operators in Dart

Dart allows you to override the following operators:

  • Arithmetic: +, -, *, /, ~/ (integer division), % (modulo), unary -
  • Comparison: ==, <, >, <=, >=
  • Bitwise: &, |, ^, ~ (bitwise NOT), <<, >>
  • Index: [], []=
Warning: You cannot override = (assignment), &&, ||, !, is, is!, as, ??, or ?. -- these are built into the language and cannot be customized.

Practical Example: Vector2D Class

Let’s build a 2D vector class that supports arithmetic operations. This is one of the most common use cases for operator overloading in game development and graphics programming.

Vector2D with Operator Overloading

class Vector2D {
  final double x;
  final double y;

  const Vector2D(this.x, this.y);

  // Vector addition: v1 + v2
  Vector2D operator +(Vector2D other) {
    return Vector2D(x + other.x, y + other.y);
  }

  // Vector subtraction: v1 - v2
  Vector2D operator -(Vector2D other) {
    return Vector2D(x - other.x, y - other.y);
  }

  // Scalar multiplication: v * scalar
  Vector2D operator *(double scalar) {
    return Vector2D(x * scalar, y * scalar);
  }

  // Scalar division: v / scalar
  Vector2D operator /(double scalar) {
    if (scalar == 0) throw ArgumentError('Cannot divide by zero');
    return Vector2D(x / scalar, y / scalar);
  }

  // Negation: -v
  Vector2D operator -() {
    return Vector2D(-x, -y);
  }

  // Equality: v1 == v2
  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Vector2D && other.x == x && other.y == y;
  }

  @override
  int get hashCode => Object.hash(x, y);

  // Magnitude (length) of the vector
  double get magnitude => (x * x + y * y).sqrt();

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

void main() {
  final v1 = Vector2D(3, 4);
  final v2 = Vector2D(1, 2);

  print(v1 + v2);    // Vector2D(4.0, 6.0)
  print(v1 - v2);    // Vector2D(2.0, 2.0)
  print(v1 * 2);     // Vector2D(6.0, 8.0)
  print(v1 / 2);     // Vector2D(1.5, 2.0)
  print(-v1);        // Vector2D(-3.0, -4.0)

  // Chaining operators
  final result = (v1 + v2) * 3;
  print(result);     // Vector2D(12.0, 18.0)
}
Tip: Notice that each operator returns a new Vector2D instance rather than modifying the existing one. This makes the class immutable, which is a best practice for value objects. Immutable objects are safer in concurrent code and easier to reason about.

Practical Example: Money Class

In financial applications, representing money as a class with operator overloading prevents common floating-point errors and enforces currency safety.

Money Class with Safe Arithmetic

class Money {
  final int _cents; // Store as cents to avoid floating-point issues
  final String currency;

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

  // Factory from dollars
  factory Money.fromDollars(double dollars, String currency) {
    return Money((dollars * 100).round(), currency);
  }

  double get dollars => _cents / 100;

  // Addition: m1 + m2
  Money operator +(Money other) {
    _checkCurrency(other);
    return Money(_cents + other._cents, currency);
  }

  // Subtraction: m1 - m2
  Money operator -(Money other) {
    _checkCurrency(other);
    return Money(_cents - other._cents, currency);
  }

  // Scalar multiplication: money * quantity
  Money operator *(int multiplier) {
    return Money(_cents * multiplier, currency);
  }

  // Comparison operators
  bool operator <(Money other) {
    _checkCurrency(other);
    return _cents < other._cents;
  }

  bool operator >(Money other) {
    _checkCurrency(other);
    return _cents > other._cents;
  }

  bool operator <=(Money other) {
    _checkCurrency(other);
    return _cents <= other._cents;
  }

  bool operator >=(Money other) {
    _checkCurrency(other);
    return _cents >= other._cents;
  }

  // Negation: -money
  Money operator -() {
    return Money(-_cents, currency);
  }

  void _checkCurrency(Money other) {
    if (currency != other.currency) {
      throw ArgumentError(
        'Cannot operate on different currencies: $currency vs ${other.currency}',
      );
    }
  }

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Money && other._cents == _cents && other.currency == currency;
  }

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

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

void main() {
  final price = Money.fromDollars(29.99, 'USD');
  final tax = Money.fromDollars(2.40, 'USD');
  final total = price + tax;

  print(total);            // USD 32.39
  print(price * 3);        // USD 89.97
  print(price > tax);      // true
  print(price + tax);      // USD 32.39

  // Currency mismatch throws an error
  final euro = Money.fromDollars(25.00, 'EUR');
  // price + euro;  // ERROR: Cannot operate on different currencies
}
Design Note: The _checkCurrency method ensures you never accidentally mix USD and EUR in calculations. This is a great example of how operator overloading can enforce business rules at compile time rather than relying on developer discipline.

Index Operators: [] and []=

The index operators let your objects behave like lists or maps. This is useful for matrix classes, custom collections, or configuration objects.

Matrix Class with Index Operators

class Matrix {
  final List<List<double>> _data;
  final int rows;
  final int cols;

  Matrix(this.rows, this.cols)
      : _data = List.generate(rows, (_) => List.filled(cols, 0.0));

  Matrix.fromData(this._data)
      : rows = _data.length,
        cols = _data.isEmpty ? 0 : _data[0].length;

  // Access a row: matrix[row]
  List<double> operator [](int row) {
    if (row < 0 || row >= rows) {
      throw RangeError('Row $row is out of range [0, $rows)');
    }
    return _data[row];
  }

  // Matrix addition: m1 + m2
  Matrix operator +(Matrix other) {
    if (rows != other.rows || cols != other.cols) {
      throw ArgumentError('Matrices must have the same dimensions');
    }
    final result = Matrix(rows, cols);
    for (int i = 0; i < rows; i++) {
      for (int j = 0; j < cols; j++) {
        result[i][j] = _data[i][j] + other._data[i][j];
      }
    }
    return result;
  }

  // Scalar multiplication: matrix * scalar
  Matrix operator *(double scalar) {
    final result = Matrix(rows, cols);
    for (int i = 0; i < rows; i++) {
      for (int j = 0; j < cols; j++) {
        result[i][j] = _data[i][j] * scalar;
      }
    }
    return result;
  }

  @override
  String toString() {
    return _data.map((row) => row.map((v) => v.toStringAsFixed(1)).join('\t')).join('\n');
  }
}

void main() {
  final m1 = Matrix.fromData([
    [1, 2, 3],
    [4, 5, 6],
  ]);

  final m2 = Matrix.fromData([
    [7, 8, 9],
    [10, 11, 12],
  ]);

  // Access elements
  print(m1[0][1]);  // 2.0 -- row 0, column 1

  // Set elements
  m1[0][2] = 99;
  print(m1[0][2]);  // 99.0

  // Matrix addition
  final sum = m1 + m2;
  print(sum);
  // 108.0  10.0  12.0
  // 14.0   16.0  18.0

  // Scalar multiplication
  final scaled = m2 * 2;
  print(scaled);
  // 14.0  16.0  18.0
  // 20.0  22.0  24.0
}

Comparison Operators for Sorting

Overloading comparison operators like < and > makes your objects sortable and usable with Dart’s built-in sorting and comparison utilities.

Temperature Class with Comparisons

class Temperature implements Comparable<Temperature> {
  final double celsius;

  const Temperature(this.celsius);

  factory Temperature.fromFahrenheit(double f) {
    return Temperature((f - 32) * 5 / 9);
  }

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

  // Comparison operators
  bool operator <(Temperature other) => celsius < other.celsius;
  bool operator >(Temperature other) => celsius > other.celsius;
  bool operator <=(Temperature other) => celsius <= other.celsius;
  bool operator >=(Temperature other) => celsius >= other.celsius;

  // Arithmetic: adding/subtracting temperature differences
  Temperature operator +(Temperature other) => Temperature(celsius + other.celsius);
  Temperature operator -(Temperature other) => Temperature(celsius - other.celsius);

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Temperature && other.celsius == celsius;
  }

  @override
  int get hashCode => celsius.hashCode;

  // Required by Comparable interface
  @override
  int compareTo(Temperature other) => celsius.compareTo(other.celsius);

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

void main() {
  final temps = [
    Temperature(100),
    Temperature(0),
    Temperature(37),
    Temperature.fromFahrenheit(212),
  ];

  // Sorting works because we implemented Comparable and <
  temps.sort();
  print(temps);  // [0.0°C, 37.0°C, 100.0°C, 100.0°C]

  final boiling = Temperature(100);
  final freezing = Temperature(0);
  print(boiling > freezing);   // true
  print(boiling == Temperature(100));  // true
}
Best Practice: When overloading comparison operators, also implement the Comparable interface. This lets your class work with List.sort(), SplayTreeSet, and other standard library utilities that rely on compareTo().

Real-World Example: Color Blending

Here is a practical example that shows operator overloading in a Flutter-like context -- a color class that supports blending and manipulation through operators.

Color Class with Operator Overloading

class AppColor {
  final int r, g, b;
  final double opacity;

  const AppColor(this.r, this.g, this.b, [this.opacity = 1.0])
      : assert(r >= 0 && r <= 255),
        assert(g >= 0 && g <= 255),
        assert(b >= 0 && b <= 255),
        assert(opacity >= 0.0 && opacity <= 1.0);

  // Blend two colors: color1 + color2
  AppColor operator +(AppColor other) {
    return AppColor(
      ((r + other.r) / 2).round().clamp(0, 255),
      ((g + other.g) / 2).round().clamp(0, 255),
      ((b + other.b) / 2).round().clamp(0, 255),
      ((opacity + other.opacity) / 2).clamp(0.0, 1.0),
    );
  }

  // Darken: color * factor (0.0 to 1.0)
  AppColor operator *(double factor) {
    return AppColor(
      (r * factor).round().clamp(0, 255),
      (g * factor).round().clamp(0, 255),
      (b * factor).round().clamp(0, 255),
      opacity,
    );
  }

  // Invert: ~color
  AppColor operator ~() {
    return AppColor(255 - r, 255 - g, 255 - b, opacity);
  }

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is AppColor &&
        other.r == r &&
        other.g == g &&
        other.b == b &&
        other.opacity == opacity;
  }

  @override
  int get hashCode => Object.hash(r, g, b, opacity);

  @override
  String toString() => 'AppColor($r, $g, $b, ${opacity.toStringAsFixed(2)})';
}

void main() {
  final red = AppColor(255, 0, 0);
  final blue = AppColor(0, 0, 255);

  // Blend colors
  final purple = red + blue;
  print(purple);       // AppColor(128, 0, 128, 1.00)

  // Darken
  final darkRed = red * 0.5;
  print(darkRed);      // AppColor(128, 0, 0, 1.00)

  // Invert
  final invertedRed = ~red;
  print(invertedRed);  // AppColor(0, 255, 255, 1.00)
}
Important: Only overload operators when the meaning is intuitive. If + on your class does not feel like “adding” or “combining”, use a named method instead. Confusing operator semantics make code harder to understand. A good rule: if you have to explain what the operator does, use a method name.
Summary: Operator overloading in Dart lets you define custom behavior for standard operators using the operator keyword. It makes classes like vectors, money, matrices, and colors more natural to use. Always return new immutable instances, validate inputs, and only overload operators when the meaning is clear and intuitive.