Created
November 23, 2023 18:55
-
-
Save Stroniax/31b9ac790b597c1568ddea8a48469b16 to your computer and use it in GitHub Desktop.
This file contains 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 Xunit; | |
using System.Diagnostics; | |
using System.Diagnostics.CodeAnalysis; | |
namespace ProofOfConcept.ClearSingleton; | |
public sealed class Singleton : IDisposable | |
{ | |
private static Singleton? _instance; | |
private Singleton() { } | |
[return: NotNullIfNotNull(nameof(_instance))] | |
public static ref readonly Singleton? Load() | |
{ | |
if (_instance is null) | |
{ | |
// In a single-threaded environment, this will initialize the instance once. | |
// If we are running multi-threaded, this ensures _instance refers to only one | |
// item. However, it is possible that a caller will use Dispose() after we initialize | |
// the instance, and then the returned pointer will refer to a null object. | |
var instance = new Singleton(); | |
Interlocked.CompareExchange(ref _instance, instance, null); | |
} | |
// Since we return a reference to this field, this type is not thread-safe when | |
// captured as a ref variable (eg `ref readonly var a = ref Singleton.Load()`). | |
// Because any caller on another thread could Dispose it, so our reference `a` would | |
// now be null. | |
return ref _instance; | |
} | |
/// <remarks> | |
/// We could name this something like Unload or Clear instad of Dispose and not use the | |
/// <see cref="IDisposable"/> interface. I decided to use <see cref="IDisposable"/> because | |
/// callers will understand that <see cref="ObjectDisposedException"/> may be thrown if | |
/// the instance is no longer valid. | |
/// </remarks> | |
public void Dispose() | |
{ | |
// This could just as easily be a static method that takes `this` as a parameter instead. | |
// Honestly that would be more thread-safe because as an instance method, it's possible | |
// to try to call Dispose on a ref instance that was set to null, which would throw a | |
// NullReferenceException. | |
Interlocked.CompareExchange(ref _instance, null, this); | |
} | |
public void DoSomeWork() | |
{ | |
if (ReferenceEquals(this, Interlocked.CompareExchange(ref _instance, this, this))) | |
{ | |
// this is the current instance and our work is valid (assuming this instance is not | |
// disposed during this method call; to ensure that doesn't happen we'd likely want to | |
// introduce some locking mechanism) | |
} | |
// this is a stale instance that has been disposed | |
throw new ObjectDisposedException(nameof(Singleton)); | |
} | |
} | |
public class SingletonTests | |
{ | |
/// <summary> | |
/// This test demonstrates that <see cref="Singleton.Load"/> returns a non-null instance. | |
/// </summary> | |
[Fact] | |
public void Load_ReturnsNotNullInstance() | |
{ | |
// Since we want to capture a refernence to the variable so that it can be set null | |
// by some other operation like Dispose, we need to capture it as a ref variable. | |
ref readonly var instance = ref Singleton.Load(); | |
// Ensures Load does not return null | |
Assert.NotNull(instance); | |
// If this test runs in parallel you might see a null value here. Consider using a | |
// non-ref variable; but really, none of these singleton tests are thread-safe which | |
// is one reason that mutable singletons aren't that great in production. | |
// And why would you need to be able to clear a singleton if it's immutable? | |
} | |
/// <summary> | |
/// This test demonstrates that <see cref="Singleton.Load"/> returns the same | |
/// instance when called multiple times. | |
/// </summary> | |
[Fact] | |
public void Load_ReturnsSameInstance() | |
{ | |
ref readonly var instance1 = ref Singleton.Load(); | |
ref readonly var instance2 = ref Singleton.Load(); | |
Assert.Same(instance1, instance2); | |
} | |
/// <summary> | |
/// This test demonstrates that after calling <see cref="Singleton.Dispose"/> on the | |
/// <see langword="ref readonly"/> variable returned from <see cref="Singleton.Load"/>, | |
/// that variable will now have a null value. | |
/// </summary> | |
[Fact] | |
public void Dispose_RefInstanceNull() | |
{ | |
// capture the variable as a ref variable so that when the field it points to is set | |
// to null, so is our variable | |
ref readonly var instance = ref Singleton.Load(); | |
// Debug assertion tells the compiler not to warn us that this is null | |
Debug.Assert(instance is not null); | |
// Dispose the current instance. This sets our field, which the variable refers to, to null | |
instance.Dispose(); | |
// Ensures that the instance was set to null | |
Assert.Null(instance); | |
} | |
/// <summary> | |
/// This test demonstrates that <see cref="Singleton.Dispose"/> only disposes the instance it is | |
/// called on; if an old/stale instance is disposed, nothing will happen because the singleton | |
/// refers to a different instance. | |
/// </summary> | |
[Fact] | |
public void Dispose_FromOldInstance_DoesNotSetNull() | |
{ | |
// capture the first variable as a non-ref variable so that we can Dispose it again later | |
var instance = Singleton.Load(); | |
// Debug assertion tells the compiler not to warn us that this is null | |
Debug.Assert(instance is not null); | |
// Dispose the instance | |
instance.Dispose(); | |
// Capture a new instance in a ref variable so that it will be cleared if | |
// the singleton instance is cleared | |
ref readonly var second = ref Singleton.Load(); | |
Assert.NotNull(second); | |
Assert.NotSame(instance, second); | |
// This should be a no-op now because the singleton does not refer to this instance | |
instance.Dispose(); | |
// Ensures that the second instance was NOT set to null this time | |
Assert.NotNull(second); | |
// Ensures that the singleton still refers to the second instance | |
ref readonly var third = ref Singleton.Load(); | |
Assert.Same(third, second); | |
} | |
/// <summary> | |
/// This test demonstrates that the <see cref="Singleton.Dispose"/> method | |
/// <i>only</i> sets the variable to null <i>if</i> the variable was captured | |
/// as a <see langword="ref readonly"/> variable; assigning it to a regular | |
/// variable will retain a reference to the value. | |
/// </summary> | |
[Fact] | |
public void Dispose_WhenNotRef_VariableNotCleared() | |
{ | |
// capture the first variable as a non-ref variable so that it doesn't point to | |
// a field that will be set null | |
var instance = Singleton.Load(); | |
// Debug assertion tells the compiler not to warn us that this is null | |
Debug.Assert(instance is not null); | |
// This sets a ref instance to null, but since this time `instance` is | |
// not a ref, it will not be cleared. | |
instance.Dispose(); | |
Assert.NotNull(instance); | |
} | |
/// <summary> | |
/// This test example demonstrates that the instance pointed to in the | |
/// <see langword="ref"/> variable returned from <see cref="Singleton.Load"/> | |
/// <i>does</i> point to the new singleton instance if it is assigned. | |
/// </summary> | |
[Fact] | |
public void Dispose_Load_RefVariablePointsToNewInstance() | |
{ | |
ref readonly var instance = ref Singleton.Load(); | |
// Capture the instance in a regular variable so we can ensure it changes. | |
var oldInstance = instance; | |
Debug.Assert(instance is not null); | |
instance.Dispose(); | |
ref readonly var newInstance = ref Singleton.Load(); | |
// Both variables should point to the same instance. | |
Assert.Same(instance, newInstance); | |
// We can be sure that the instance WAS changed - we aren't referring to the | |
// same value as we had initially | |
Assert.NotSame(instance, oldInstance); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment