Skip to content

Instantly share code, notes, and snippets.

@jnm2
Last active February 19, 2025 16:36
Show Gist options
  • Save jnm2/7a53a3a6683bdc2b7af9193661238f11 to your computer and use it in GitHub Desktop.
Save jnm2/7a53a3a6683bdc2b7af9193661238f11 to your computer and use it in GitHub Desktop.
ObservableObject
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;
}
}
#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