Skip to content

Instantly share code, notes, and snippets.

@Stroniax
Created November 23, 2023 18:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Stroniax/31b9ac790b597c1568ddea8a48469b16 to your computer and use it in GitHub Desktop.
Save Stroniax/31b9ac790b597c1568ddea8a48469b16 to your computer and use it in GitHub Desktop.
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