LazyValue.cs is an extremely valuable tool for dealing with race conditions. In fact, I believe you were asking earlier about a way to see if a save file was present to avoid using a -1 as a default trigger to initialize CurrentHealth. I showed you how to see if it existed, but cautioned against it’s use. LazyValue is the correct approach. It’s a feature that appears in some other programming languages (Kotlin immediately comes to mind), but isn’t innately supported by C# or Unity, so we had to roll our own version of it. (There actually is a Lazy class in the system libraries, but it’s designed for true multi-threaded applications and is needlessly complex for using in Unity).
The core of the system is actually relatively simple. The class is generic, much like List and Dictionary. This allows us to use just about any type as a Lazy Value.
We have an initialization function. This is a recipe for how to get the correct value if the value hasn’t been established already. Whenever the LazyValue is accessed, we first check to see if it’s been initialized. If it hasn’t, then we initialize it.
If this sounds familiar, it’s because it’s exactly what we did manually in Progression.cs. The BuildLookup in that class checks to see if the Dictionary exists, and if it doesn’t, then it creates the dictionary and populates it. This is a classic form of Lazy Initialization, and we’re effectively using the same pattern here, but making it generic so that it can be used with any class… You could use the same method we used in Progression for each troublesome value, this just condenses it into one easy to use generic class.
It’s important to note that some basic rules still need to be followed. It’s imperative that at no point you access a LazyValue that references an external source in Awake(). For example, in Health.cs:
void Awake()
{
currentHealth = new LazyValue(GetComponent<BaseStats>().GetStat(Stat.Health));
Debug.Log($"{currentHealth.value}");
}
Why? Because BaseStats GetStat(Stat.Health) depends on the level of the character. For an enemy, the level is already set in the inspector, but for the player, the level may or may not have been initialized.
Once you’ve passed Awake() and gotten into OnEnable, the level should either be correctly set, or if it’s a LazyValue, should be accessible (because we set the LazyValue to use CalculateLevel() in Awake().
Now all of that being said, most race conditions ARE avoidable without LazyValue, following these simple rules:
- Awake() → You may cache references to components here. You may not USE those external references in any way. You may, however, set the value to variables that do not rely on external components.
- OnEnable()/OnDisable() → You may subscribe and unsubscribe to events here.
- Start()/Update() → You should be able to safely access values in other components.
Note that there are still times when following these simple rules will still require some form of Lazy Evaluation to avoid race conditions. Race conditions are some of the hardest bugs to track down and eliminate, which is why Lazy Evaluation is such an important tool.