12. Read-Only Members

  03. Features No Comments

Sometimes you may want to make use of read-only fields or get-only properties in your components and scriptable objects. Read-only members make your data immutable and as such result in code that is less prone to errors and fully thread safe without needing any complicated thread locking.

The issue is that you can’t pass any constructor arguments to components or scriptable objects when creating them in Unity by default.

Inity offers three different solutions for this issue:

  1. Define the read-only members in a and use a wrapper component to attach it to GameObject.
  2. Assign arguments passed to the Init function to read-only members using reflection.
  3. Retrieve initialization arguments inside the parameterless constructor via InitArgs.

For more information about using plain old class objects to achieve this refer to the documentation for the Wrapper<T> class.

Using Reflection

The MonoBehaviour<T…> and ScriptableObject<T…> base classes contain custom indexers that allow you to assign arguments that have been passed to your Init function into fields or properties even if they are read-only. This is possible thanks to the usage of reflection behind the scenes.

The syntax for assigning to read-only members inside the Init function looks like this:

public class Player : MonoBehaviour<IInputManager>
{
    public readonly IInputManager inputManager;

    protected override void Init(IInputManager inputManager)
    {
        this[nameof(inputManager)] = inputManager;
    }
}

Note that there is a performance cost to using this method, because setting values using reflection is something like 10 times slower than assigning them directly. However, for classes that don’t get initialized that often during gameplay, for example only during initial loading, this cost in performance isn’t that bad and most likely won’t even be noticeable in practice.

Using The Default Constructor

Another way to assign to read-only members with Inity is to manually receive initialization arguments in the parameterless constructor and assign them to read-only members there.

To achieve this make sure your class implements an IArgs<T…> interface with generic argument types matching the types of the arguments that the class can receive, and then use InitArgs.TryGet to retrieve arguments inside the constructor.

If your class can not function without receiving these arguments, then it is also advisable to log an error or throw an exception in the constructor if no arguments have been injected for it.

public class Player : MonoBehaviour, IArgs<IInputManager>
{
    public readonly IInputManager inputManager;

    public Player()
    {
        if(InitArgs.TryGet(Context.Constructor, this, out inputManager))
        {
            throw new MissingInitArgumentsException(this);
        }
    }
}

While this method of assigning to read-only members works without requiring any reflection, there are some big limitations to it as well (there is a reason why the MonoBehaviour<T…> classes use Awake instead of the constructor to receive arguments by default).

  1. The constructor gets invoked before the deserialization process takes place. This means that if you assign arguments to any fields that are serialized, those values will be overridden during deserialization of scene objects and objects instantiated from prefabs. This issue can be avoided by building your components at runtime from scratch using AddComponent or new GameObject<T…>, or by taking care to only assign values to non-serialized members.
  2. Constructors for scene objects are called before the Awake methods of any other objects in the same scene or instantiated prefab, regardless of Script Execution Order settings. This means, for one, that Initializers are not able to inject arguments for clients in the same scene before the constructors are already executed.
  3. The constructor gets called for scene objects before a scene has finished fully loading. In the very first scene of the game constructors can even get called before static methods that have the RuntimeInitializeOnLoadMethod attribute – which is what is used to initialize all services in Inity! This means that if you have any components using constructor injection in the very first scene that is loaded when running the game, you might not be able to retrieve any services during their initialization.

For the above reasons it is not recommended to use this pattern at all with scene objects, and even with prefabs that are instantiated you have to be careful to avoid assigning arguments into serialized fields. On the other hand for components that you know will be added to GameObjects at runtime this pattern can work really well.

Hybrid Solution

Note that you don’t necessarily have to pick between using reflection or the default constructor for assigning arguments to read-only members: there is nothing stopping you from supporting both options.

public class Player : MonoBehaviour<IInputManager>
{
        public readonly IInputManager inputManager;

        public Player()
        {
                InitArgs.TryGet(Context.Constructor, this, out inputManager);
        }

        protected override void Init(IInputManager inputManager)
        {
                this[nameof(inputManager)] = inputManager;
        }
}

In the above example the Player class will receive intialization arguments manually inside its parameterless constructor, if they have been provided already at this point. This option will get used when the object is created procedurally at runtime using AddComponent, Instantiate or new GameObject<T…>.

In addition to this the class also supports external classes manually invoking its Init function. This makes it possible for Initializers to also target instances of this class in scenes and prefabs.

With this approach you can get maximal performance whenever possible without making sacrifices on the reliability front.

Leave a Reply

Your email address will not be published. Required fields are marked *