Last active
February 19, 2025 16:36
-
-
Save jnm2/7a53a3a6683bdc2b7af9193661238f11 to your computer and use it in GitHub Desktop.
ObservableObject
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.ComponentModel; | |
using System.Runtime.CompilerServices; | |
public abstract class ObservableObject : INotifyPropertyChanged | |
{ | |
public event PropertyChangedEventHandler? PropertyChanged; | |
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) | |
{ | |
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); | |
} | |
protected bool Set<T>( | |
[NotNullIfNotNull(nameof(value))] ref T location, | |
T value, | |
[CallerMemberName] string? propertyName = null) | |
{ | |
var shouldDetectChange = !EqualityComparer<T>.Default.Equals(location, value); | |
location = value; | |
if (!shouldDetectChange) return false; | |
OnPropertyChanged(propertyName); | |
return true; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#nullable enable // Important for "compiler warnings as test assertions" model below | |
public static class ObservableObjectTests | |
{ | |
private class Nullable_backing_field_does_not_require_suppression_on_Set : ObservableObject | |
{ | |
[field: MaybeNull, AllowNull] | |
public string NotNullProp | |
{ | |
set | |
{ | |
// This is a test which asserts via compiler warning rather than by running a test. | |
// When no '!' is used here, there should be no warnings: | |
Set(ref field, value); | |
// No warning here either, due to nullability annotations on Set showing that 'field' has been set to | |
// 'value', since 'value' is not null: | |
_ = field.Length; | |
} | |
} | |
} | |
private class Generic_backing_field_does_not_require_suppression_on_Set<T> : ObservableObject | |
{ | |
public required T UnconstrainedGenericProp | |
{ | |
// This is a test which asserts via compiler warning rather than by running a test. | |
// When no '!' is used here, there should be no warnings: | |
set => Set(ref field, value); | |
} | |
} | |
private class Maybe_null_generic_backing_field_does_not_require_suppression_on_Set<T> : ObservableObject | |
{ | |
[field: MaybeNull, AllowNull] | |
public T UnconstrainedGenericProp | |
{ | |
// This is a test which asserts via compiler warning rather than by running a test. | |
// When no '!' is used here, there should be no warnings: | |
set => Set(ref field, value); | |
} | |
} | |
private class Not_null_generic_property_does_not_require_suppression_after_Set<T> : ObservableObject | |
where T : notnull | |
{ | |
[field: MaybeNull, AllowNull] | |
public T UnconstrainedGenericProp | |
{ | |
set | |
{ | |
Set(ref field, value); | |
// No warning here either, due to nullability annotations on Set showing that 'field' has been set to | |
// 'value', since 'value' is not null: | |
_ = field.GetHashCode(); | |
} | |
} | |
} | |
[Test] | |
public static void Setting_to_same_value_does_not_raise_event() | |
{ | |
var obj = new ExampleObject<int> { Prop = 1 }; | |
obj.PropertyChanged += (_, _) => Assert.Fail(); | |
obj.Prop = obj.Prop; | |
} | |
[Test] | |
public static void User_defined_equality_suppresses_change_detection_for_value_types() | |
{ | |
var obj = new ExampleObject<decimal> { Prop = 0.3000m }; | |
// It would only be in special scenarios that we might wish to raise PropertyChanged when the numeric value | |
// remains the same, but the precision is being changed. Using RuntimeHelpers.Equals instead of | |
// EqualityComparer<T>.Default.Equals to compare the decimals would catch this, it but has a bug on .NET | |
// Framework (https://github.com/dotnet/runtime/issues/98875), and is not necessarily what we would want for | |
// other value types. Decimal could be special-cased via Decimal.GetBits to sniff the precision (there's also a | |
// Scale property in .NET 7+). Adding special cases to Set<T> may or may not be palatable. Decimal properties | |
// that need this may benefit from manual implementations that don't use Set<T>, in the sense that the manual | |
// handling draws beneficial attention to the special scenario. It may also be clearer and more direct to use | |
// String rather than Decimal as the data-binding type in cases where the precision can't be set in the UI | |
// control. | |
obj.PropertyChanged += (_, _) => Assert.Fail(); | |
obj.Prop.ToString(CultureInfo.InvariantCulture).ShouldBe("0.3000", "For demonstration"); | |
obj.Prop.ToString("G", CultureInfo.InvariantCulture).ShouldBe("0.3000", "For demonstration"); | |
obj.Prop = 0.3m; | |
obj.Prop.ToString(CultureInfo.InvariantCulture).ShouldBe("0.3", "For demonstration"); | |
obj.Prop.ToString("G", CultureInfo.InvariantCulture).ShouldBe("0.3", "For demonstration"); | |
} | |
[Test] | |
public static void User_defined_equality_suppresses_change_detection_for_reference_types() | |
{ | |
var obj = new ExampleObject<string> { Prop = "A" }; | |
// It would be cruel and unusual punishment to force code that sets view model properties to worry about whether | |
// strings with the same contents are the same reference. In order to avoid pushing that worry onto that code, | |
// ObservableObject needs to allow the type (such as String) itself to determine equality; if it doesn't, a | |
// stack overflow may occur when the property setter fails to detect equal-contents strings as "no change." If | |
// the object has events that need to be registered and deregistered based on PropertyChanged being raised, the | |
// object should not be defining custom equality. When events are in play, or more generally when "which one" | |
// becomes an interesting question, references are no longer interchangeable and custom equality should not be | |
// defined. | |
obj.PropertyChanged += (_, _) => Assert.Fail(); | |
var differentStringWithSameContents = new string('A', count: 1); | |
obj.Prop.ShouldNotBeSameAs(differentStringWithSameContents, "For demonstration"); | |
obj.Prop.ShouldBe(differentStringWithSameContents, "For demonstration"); | |
obj.Prop = new string('A', count: 1); | |
} | |
private class ExampleObject<TProp> : ObservableObject | |
{ | |
public required TProp Prop { get; set => Set(ref field, value); } | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment