Search Unity

Code Design: Abstracting Singletons

Discussion in 'Scripting' started by JoshuaMcKenzie, Feb 29, 2016.

  1. JoshuaMcKenzie

    JoshuaMcKenzie

    Joined:
    Jun 20, 2015
    Posts:
    916
    I've been aware of the pitfalls that can plague singletons, specifically how big of a hit they can do on code maintenance and testing. This is mostly due to how tightly coupled they are with every class that uses them (since those classes have to actually know the name of the Singleton's class to use them).

    So mostly out of curiosity, I set out to see if I could encapsulate my singletons. Enforcing that they are accessed as an abstraction, not as a concrete class. I must say that even though this is still a work in progress, I'm really liking how its turning out. Simply by getting them decoupled solves so many issues that shadowed singletons. Not the threading issues, but I'm not concerned on making thread-safe singletons (plus theres already ways to make singletons thread-safe).

    So how did I do it?

    first off I looked to a few design principals and made sure that the classes can only access each other through either an interface or an extension method. So lets start off with the Interfaces used
    Code (CSharp):
    1. public interface ISingletonController
    2. {
    3.     // gives callers knowledge if a call to Controller will/should fail
    4.     bool Availible{get;}
    5.     //Add a Singleton to the Singleton Manager
    6.     bool AddSingleton(ISubSingleton item);
    7.     // remove a Singleton from the Singleton Manager
    8.     bool RemoveSingleton(ISubSingleton item);
    9.     // get the first Singleton that implements an interface in the Singleton Manager
    10.     T    GetSingletonAsInterface<T>() where T:class;
    11.     // get all Singletons that implements an interface in the Singleton Manager
    12.     T[]  GetSingletonsAsInterface<T>() where T:class;
    13. }
    14.  
    15.  
    16. public interface ISubSingleton
    17. {
    18.     //if a ISubSingleton wants to be notified that a Level was loaded
    19.     void OnLevelLoaded();
    20.     //if a ISubSingleton wants to be notified that the Application is quitting
    21.     void OnApplicationQuit();
    22. }
    then I made a manager class that would serve as the operator for accessing any or all singletons. It holds an entire collection of singletons that's encapsulated away from all my Monobehaviours (in that they aren't accessed concretely). The Manager to singleton relation is pretty similar to the GameObject to Component, but instead of communicating through Reflection with SendMessage, they communicate through interfaces (which should be a lot faster).

    Code (CSharp):
    1. public class SingletonManager : ISingletonController
    2. {
    3.     //just using lists for ease of use. if needed I'll move to arrays later
    4.     private IList<ISubSingleton> singletons = new List<ISubSingleton>();
    5.     private SingletonManager(){}
    6.  
    7.     //this flag is used to prevent Singletons from getting added on application quit
    8.     private static bool isDestroying = false;
    9.  
    10.     private static SingletonManager instance;
    11.     public static ISingletonController Instance
    12.     {
    13.         get
    14.         {
    15.             if(Utilities.IsNull(instance))
    16.             {
    17.                 if(isDestroying)
    18.                 {
    19.                     return null;
    20.                 }
    21.  
    22.                 instance = new SingletonManager();
    23.             }
    24.  
    25.             return instance as ISingletonController;
    26.         }
    27.     }
    28.  
    29.     #region ISingletonController implementation
    30.  
    31.     public bool Availible{get{return !isDestroying;}}
    32.  
    33.     public bool AddSingleton(ISubSingleton item)
    34.     {
    35.         if(isDestroying)
    36.         {
    37.             return false;
    38.         }
    39.  
    40.         if(singletons.Contains(item))
    41.         {
    42.             return false;
    43.         }
    44.  
    45.         singletons.Add(item);
    46.         return true;
    47.  
    48.     }
    49.  
    50.     public bool RemoveSingleton(ISubSingleton item)
    51.     {
    52.  
    53.         if(!singletons.Contains(item))
    54.         {
    55.             return false;
    56.         }
    57.      
    58.         singletons.Remove(item);
    59.         return true;
    60.     }
    61.  
    62.     public T GetSingletonAsInterface<T>() where T : class
    63.     {
    64.         if(!typeof(T).IsInterface)
    65.         {
    66.             throw new  System.InvalidOperationException
    67.                 ("You can only grab Singletons as interface types");
    68.         }
    69.  
    70.         if(isDestroying)
    71.         {
    72.             return null;
    73.         }
    74.  
    75.         foreach(ISubSingleton subSingleton in singletons)
    76.         {
    77.             if(subSingleton is T)
    78.             {
    79.                 return subSingleton as T;
    80.             }
    81.         }
    82.  
    83.         return null;
    84.     }
    85.  
    86.     public T[] GetSingletonsAsInterface<T>() where T : class
    87.     {
    88.         if(!typeof(T).IsInterface)
    89.         {
    90.             throw new  System.InvalidOperationException
    91.                 ("You can only grab Singletons as interface types");
    92.         }
    93.  
    94.  
    95.         if(isDestroying)
    96.         {
    97.             return new T[0];
    98.         }
    99.  
    100.  
    101.      
    102.      
    103.         List<T> group = new List<T>();
    104.         foreach(ISubSingleton subSingleton in singletons)
    105.         {
    106.             if(subSingleton is T)
    107.             {
    108.                 group.Add(subSingleton as T);
    109.             }
    110.         }
    111.      
    112.         return group.ToArray();
    113.     }
    114.  
    115.     #endregion
    116.  
    117.     //requires Unity 5+ and calls after Awake
    118.     //(i'm currently using Unity 5.1.1 so I can't use
    119.     // the LoadTypes to call before scene load)
    120.     [RuntimeInitializeOnLoadMethod]
    121.     // notifies all singletons when a level fully loads.
    122.     static void OnLevelLoaded()
    123.     {
    124.         ISingletonController current = Instance;
    125.  
    126.         if(isDestroying || Utilities.IsNull(instance))
    127.         {
    128.             return;
    129.         }
    130.  
    131.         foreach(ISubSingleton sub in instance.singletons)
    132.         {
    133.             sub.OnLevelLoaded();
    134.         }
    135.     }
    136.  
    137.  
    138.  
    139.     //still looking for a way to be able to call this method without
    140.     //  having to create a GameObject and attach a Monobehavior
    141.     //  to relay that message to this class
    142.     static void OnApplicationQuit()
    143.     {
    144.         ISingletonController current = Instance;
    145.      
    146.         if(isDestroying || Utilities.IsNull(instance))
    147.         {
    148.             return;
    149.         }
    150.  
    151.         isDestroying = true;
    152.         foreach(ISubSingleton sub in instance.singletons)
    153.         {
    154.             sub.OnApplicationQuit();
    155.         }
    156.     }
    157. }
    then a a Singleton would be setup to to add itself to the manager. and each singleton would have their own interface that it assumes components will try to access it through.

    Code (CSharp):
    1. //interface for an exampleController that Components may call
    2. public interface IExampleController
    3. {
    4.     void DoSomething();
    5. }
    6.  
    7.  
    8. public class ControllerExample  : ISubSingleton, IExampleController
    9. {
    10.     public static ControllerExample instance;
    11.     //private ControllerExample(){}
    12.     [RuntimeInitializeOnLoadMethod]
    13.     private static void MakeController ()
    14.     {
    15.         if(Utilities.IsNull(instance))
    16.         {
    17.             instance = new ControllerExample ();
    18.         }
    19.  
    20.         //GetSingletonManager() is an extension method defined in SingletonManagerExtensions.cs
    21.         ISingletonController manager = instance.GetSingletonManager();
    22.  
    23.         if(Utilities.IsNull(manager) || !manager.Availible)
    24.         {
    25.             return;
    26.         }
    27.  
    28.  
    29.         manager.AddSingleton(instance as ISubSingleton);
    30.     }
    31.  
    32.  
    33.     #region ISubSingleton implementation
    34.     public void OnLevelLoaded()
    35.     {
    36.         //its reccomended that only data that doesn't rely on an
    37.         //  instance in a scene should be updated here for example
    38.         //  updating questline progression, or running an autosave
    39.         //  as a new level loads are a few examples of the kind of
    40.         //  code this should handle
    41.  
    42.  
    43.         // for data that relies on what is currently in the scene
    44.         //  (like a list of enemies in the level) is left to the
    45.         //  responsibility of those references to add/remove
    46.         //  themselves from the singleton through injection via
    47.         //  the Controller interface.
    48.  
    49.     }
    50.     public void OnApplicationQuit()
    51.     {
    52.         // in case that the application is quitting, let the
    53.         //  singletons clean themselves up and/or save their state
    54.     }
    55.     #endregion
    56.  
    57.     #region IExampleController implementation
    58.     public void DoSomething ()
    59.     {
    60.         Debug.Log("I'm doing something");
    61.     }
    62.     #endregion
    63.  
    64.  
    65. }
    66.  

    To decouple this manager from the rest of the project I use extension methods to extend the component class. As such, the extension method and the manager class itself will be the only places where the manager class is concretely referenced, leaving the rest of the project fully decoupled from the singletons and the Manager (to enforce this I can make the manager and Extensions class be in the same file then have the Manager become a private class).

    Code (CSharp):
    1.  
    2. //to maintain decoupling, all other classes must go through
    3. //this extension class to axcess the manager
    4. public static class SingletonManagerExtensions
    5. {
    6.     //functions that link components to the manager.
    7.     //  GetController will only accept interfaces to enforce decoupling
    8.     public static T GetController<T>(this Component currentComponent) where T : class
    9.     {
    10.         return SingletonManager.Instance.GetSingletonAsInterface<T>();
    11.     }
    12.  
    13.     public static T[] GetControllers<T>(this Component currentComponent) where T : class
    14.     {
    15.         return SingletonManager.Instance.GetSingletonsAsInterface<T>();
    16.     }
    17.  
    18.  
    19.  
    20.     //functions that link singleton Controllers to the manager
    21.     public static ISingletonController GetSingletonManager(this ISubSingleton SubSingleton)
    22.     {
    23.         return SingletonManager.Instance as ISingletonController;
    24.     }
    25. }

    The use of these Extension methods are pretty important as I wanted to ensure that the classes remained decoupled from the rest of the project, but still feel familiar to the GetComponent() that its inutitive to any programmer that has commonly written a Monobehaviour. So accessing a singleton in script is pretty similar to accessing a component
    Code (CSharp):
    1. //Access another Component
    2. GetComponent<Animator>();
    3.  
    4. //Access a Singleton Game Controller
    5. GetController<IGameController>();
    and since nearly everything on a gameobject is a component (including Transform which all gameobjects must have) all gameobjects can access any singleton. So a common usage of this system in any monobehaviour script is as intrusive as this...
    Code (CSharp):
    1. public class TestingScript : MonoBehaviour
    2. {
    3.     void OnTriggerEnter()
    4.     {
    5.             this.GetController<IExampleController>().DoSomething();
    6.     }
    7. }
    This is still a work in progress, but I'm liking how its looking at the moment. Currently my biggest hurdle is finding a clean way to have the SingletonManager (which is not a MonoBehaviour) listen to OnApplicationQuit without creating a proxy gameobject and monobehaviour to catch it for the class (which I really don't want to do).

    also for those curious whats the code in Utilities.IsNull. Utilities is just another extensions class I wrote which is full of some code sugar to reduce the amount of code I have to commonly write in other classes
    Code (CSharp):
    1. public static bool IsNull(object obj)
    2.     {
    3.         return obj == null || ReferenceEquals(obj,null) || obj.Equals(null);
    4.     }
    5.  
     
    Last edited: Mar 5, 2016
    Kiwasi likes this.