felangel / equatable

A Dart package that helps to implement value based equality without needing to explicitly override == and hashCode.

Home Page:https://pub.dev/packages/equatable

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Do Equatable actually need to be immutable

Nikzed opened this issue · comments

Describe the bug
We got a little argument with my colleagues and I state that we have to make all of our classes immutable and change their state with copyWith method. I've been told that there would be no difference and it will not lead to real time errors in our cases.

So the question is: Is there real case scenarios of not following immutable leading to wrong hash outcome?

I've made a test case to test the non-immutable class and was expecting it to have an error. In reality we do not have any problems with that and HashSet actually works fine here.

Can you show us a simple real case scenario where not following immutable will lead to wrong hashcode value

To Reproduce
Steps to reproduce the behavior:

import 'dart:collection';

import 'package:equatable/equatable.dart';
import 'package:flutter_test/flutter_test.dart';

class _EquatableTest extends Equatable {
  final HashSet values;

  const _EquatableTest({required this.values});

  @override
  List<Object> get props => [values];

  _EquatableTest copyWith({
    HashSet? values,
  }) {
    return _EquatableTest(
      values: values ?? this.values,
    );
  }
}

class _EquatableTestNonImmutable extends Equatable {
  HashSet values;

  _EquatableTestNonImmutable({required this.values});

  @override
  List<Object> get props => [values];
}

class _AdditionalValue extends Equatable {
  HashSet<String> letters;

  _AdditionalValue({required this.letters});

  void add(String letter) {
    letters.add(letter);
  }

  @override
  List<Object> get props => [letters];
}

void main() {
test('NonImmutable created same with _AdditionalValue value', () {
    _EquatableTest equatableImmune1;
    _EquatableTest equatableImmune2;

    equatableImmune1 = _EquatableTest(values: HashSet<_AdditionalValue>());
    equatableImmune2 = _EquatableTest(values: HashSet<_AdditionalValue>());

    equatableImmune1.values.add(_AdditionalValue(letters: HashSet.from(['a', 'b'])));
    equatableImmune1.values.add(_AdditionalValue(letters: HashSet.from(['c', 'd'])));

    equatableImmune2.values.add(_AdditionalValue(letters: HashSet.from(['a', 'b'])));
    equatableImmune2.values.add(_AdditionalValue(letters: HashSet.from(['c', 'd'])));

    expect(equatableImmune1, equatableImmune2);
  });

  test('NonImmutable changed same with _AdditionalValue value', () {
    _EquatableTest equatableImmune1;
    _EquatableTest equatableImmune2;

    equatableImmune1 = _EquatableTest(values: HashSet<_AdditionalValue>());
    equatableImmune2 = _EquatableTest(values: HashSet<_AdditionalValue>());

    _AdditionalValue value1 = _AdditionalValue(letters: HashSet<String>());
    _AdditionalValue value2 = _AdditionalValue(letters: HashSet<String>());

    value1.add('a');
    value2.add('a');

    equatableImmune1.values.add(value1);
    equatableImmune2.values.add(value2);

    expect(equatableImmune1, equatableImmune2);

    if (equatableImmune1.values.first.runtimeType == _AdditionalValue &&
        equatableImmune2.values.first.runtimeType == _AdditionalValue) {
      equatableImmune1.values.first.add('b');
      equatableImmune2.values.first.add('b');
    }

    expect(equatableImmune1, equatableImmune2);
    expect(equatableImmune1.hashCode, equatableImmune2.hashCode);
  });
}

Version
Dart SDK version: 2.19.2

The provided example does showcase some of the issues related to immutability, but it falls short in demonstrating the pitfalls of mutable objects when used in hashed collections like HashSet.

The reason your test cases might be passing (i.e., expect(equatableImmune1, equatableImmune2);) is that you are comparing objects that were effectively created to be the same, and the Equatable package is performing deep equality checks. The test doesn't simulate a scenario where you would change an object after it has been inserted into a hashed collection, which is where the issues with mutability usually arise.

Here's a modified example to showcase the point. This version shows how mutable objects can cause issues when their state changes after they've been added to a HashSet:

void main() {
  final setWithImmutable = HashSet<_EquatableTest>();
  final setWithMutable = HashSet<_EquatableTestNonImmutable>();

  final immutableObj = _EquatableTest(values: HashSet.from([1, 2, 3]));
  final mutableObj = _EquatableTestNonImmutable(values: HashSet.from([1, 2, 3]));

  // Add objects to sets
  setWithImmutable.add(immutableObj);
  setWithMutable.add(mutableObj);

  print('Before mutation:');
  print('Immutable set contains object: ${setWithImmutable.contains(immutableObj)}');  // Should print true
  print('Mutable set contains object: ${setWithMutable.contains(mutableObj)}');  // Should print true

  // Mutate the internal state of the objects
  final mutatedImmutableObj = immutableObj.copyWith(values: HashSet.from([4, 5, 6]));
  mutableObj.values = HashSet.from([4, 5, 6]);

  print('After mutation:');
  print('Immutable set contains object: ${setWithImmutable.contains(immutableObj)}');  // Should still print true
  print('Mutable set contains object: ${setWithMutable.contains(mutableObj)}');  // Should print false
}

class _EquatableTest extends Equatable {
  final HashSet values;

  const _EquatableTest({required this.values});

  @override
  List<Object?> get props => [values];

  _EquatableTest copyWith({
    HashSet? values,
  }) {
    return _EquatableTest(
      values: values ?? this.values,
    );
  }
}

class _EquatableTestNonImmutable extends Equatable {
  HashSet values;

  _EquatableTestNonImmutable({required this.values});

  @override
  List<Object?> get props => [values];
}

In this example, you'll see that after mutating the internal HashSet of the mutable object, the main HashSet (setWithMutable) can no longer recognize it. This is because its hash code changes when its internal state changes. On the other hand, the immutable object remains recognizable by the main HashSet (setWithImmutable) even after "mutation", as it actually results in a new object while keeping the original object unchanged.

Here is another example:

void main() {
  // Mutable object test
  final mutableSet = <_Mutable>{};
  final mutable = _Mutable(value: 1);
  
  // Add mutable object to set
  mutableSet.add(mutable);
  print('Mutable contains before mutation: ${mutableSet.contains(mutable)}'); // Output: true
  
  // Mutate object
  mutable.value = 2;
  
  // Check if set still contains the object
  print('Mutable contains after mutation: ${mutableSet.contains(mutable)}'); // Output: false

  // Immutable object test
  final immutableSet = <_Immutable>{};
  var immutable = _Immutable(value: 1);

  // Add immutable object to set
  immutableSet.add(immutable);
  print('Immutable contains before mutation: ${immutableSet.contains(immutable)}'); // Output: true

  // "Mutate" object by creating a new one
  immutable = immutable.copyWith(value: 2);

  // Check if set still contains the object
  print('Immutable contains after mutation: ${immutableSet.contains(immutable)}'); // Output: false

  // Check if set still contains the original object
  print('Immutable contains original after mutation: ${immutableSet.contains(_Immutable(value: 1))}'); // Output: true
}

class _Mutable {
  int value;
  _Mutable({required this.value});
  
  @override
  int get hashCode => value;
  
  @override
  bool operator ==(Object other) =>
      identical(this, other) || (other is _Mutable && other.value == value);
}

class _Immutable {
  final int value;
  _Immutable({required this.value});

  _Immutable copyWith({int? value}) {
    return _Immutable(value: value ?? this.value);
  }
  
  @override
  int get hashCode => value;
  
  @override
  bool operator ==(Object other) =>
      identical(this, other) || (other is _Immutable && other.value == value);
}