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);
}