I have a singleton that's acting as a core setup and manager for a large application. I want to restrict write access to callers outside the singleton but still allow it only if they've implemented a specific interface I define (e.g., ICoreModifier
)
The desired outcome is below:
public class Core
{
// singleton code omitted
int Property { get; set; }
}
public class A : ICoreModifier
{
// implemented ICoreModifier so things are okay
Core.GetInstance().Property = 1;
}
public class B
{
// No ICoreModifier implementation so this results in compile or runtime error
Core.GetInstance().Property = 1;
}
However, there's two issues I'm running into with this.
I'm having trouble coming up with what would actually go on the interface. This tells me I must be following the wrong design structure. I'm open to alternative approaches. I've been thinking about some sort of
Lock
andUnlock
kind of method implementation requirement, but delegating write access to the person asking to write sounds like a bad idea and probably wouldn't even work.Ideally, I'd like for this to be a compile time error rather than runtime. If that's not possible that's okay, but I'm still not quite sure how I go about doing a runtime check. My current thinking is to use reflection, but that seems messy and not very robust.
I've considered inheritance as well, but my understanding is that inheritance is with regard to is-a relationships. The classes getting write access are not cores themselves. This is why the can-do relationship interfaces provide seem most appropriate to me here.
How can I accomplish this?
CodePudding user response:
You can have an interface with nothing in it, it would be what's called a marker interface. I definitely would not delegate lock/unlock to the client.
I think this is better handled with inheritance, but not for Core, it should be for a PropertyProvider class which provides all the defaults. New PropertyProviders can be provided which overrides the properties as needed. This meets the is-a relationship.
This is nice because you mark properties as sealed when you don't want to allow users to override them, and private if you don't even want to share them.
CodePudding user response:
It is difficult to constrain behavior based on what the caller is in C#, as opposed to adding behavior to the caller.
That said, you could pass the caller itself and only allow the update if the caller is an ICoreModifier
. It is clunky and there isn't anything that prevents the caller from simply instantiating a concrete instance of ICoreModifier
, but it does give you the compile time check you are looking for:
public interface ICoreModifier { }
public class Core
{
// singleton code omitted
int Property { get; set; }
public void ModifyProperty(ICoreModifier callerInstance, int newValue)
{
if (callerInstance is null) return;
Property = newValue;
}
}
public class A : ICoreModifier
{
public A()
{
// this succeeds
new Core().ModifyProperty(this, 10);
}
}
public class B
{
public B()
{
// this throws a compile time error since this cannot be converted to ICoreModifier
new Core().ModifyProperty(this, 10);
}
}
Alternatively (and preferably), you can use namespacing to restrict access to Core by making it internal, and exposing abstraction layers that can be inherited by the client classes:
namespace Core.Internal
{
internal static class Core
{
// singleton code omitted
static internal int Property { get; set; }
}
public class CoreModifier
{
public void ModifyProperty(int newValue) => Core.Property = newValue;
}
public class CoreReader
{
public int Property = Core.Property;
}
}
public class A : Core.Internal.CoreModifier
{
public A()
{
// this succeeds
ModifyProperty(10);
}
}
public class B : Core.Internal.CoreReader
{
public B()
{
// this throws a compile time error
ModifyProperty(10);
// you can however read the property
Console.WriteLine(Property);
}
}
And finally, just for completeness' sake, you can always inspect the stack frame to check the calling method type. However, this is not advisable for a number of reasons, not the least of which is that the caller can be potentially optimized away. This implementation uses caching based on caller attributes to identify the call site to hide the cost of the StackFrame
reflection for subsequent calls:
public interface ICoreModifier { }
public static class Core
{
private static readonly ConcurrentDictionary<string, bool> s_permissionCache = new();
public static int Property { get; private set; }
public static void ModifyProperty(
int newValue,
[CallerFilePathAttribute] string filePath = default,
[CallerMemberNameAttribute] string callerMemberName = default,
[CallerLineNumberAttribute] int lineNumber = default)
{
var key = $"{filePath}_{callerMemberName}_{lineNumber}";
if (s_permissionCache.TryGetValue(key, out bool cachedValue))
{
if (!cachedValue) return;
Property = newValue;
return;
}
var type = new StackFrame(1).GetMethod().DeclaringType;
if (!type.GetInterfaces().Contains(typeof(ICoreModifier)))
{
s_permissionCache.TryAdd(key, false);
return;
}
Property = newValue;
s_permissionCache.TryAdd(key, true);
}
}
public class A : ICoreModifier
{
public void ModifyProperty(int newValue) => Core.ModifyProperty(newValue);
}
public class B
{
public void ModifyProperty(int newValue) => Core.ModifyProperty(newValue);
}
You can test this with the following:
var a = new A();
a.ModifyProperty(10);
Console.WriteLine(Core.Property);
a.ModifyProperty(12);
Console.WriteLine(Core.Property);
var b = new B();
b.ModifyProperty(14);
Console.WriteLine(Core.Property);
This prints
10
12
12
since the call to B does not meet the implemented interface criteria.