Reduce Bugs by Pruning Your Object Graph with Temporary Objects
One of the biggest source of bugs in our apps is state: all of that persistent data we keep around in memory. When things change we need to make sure to update all of it at the right times and with the right new parts of the state that changed. Inevitably things get out of sync and our app is in “a bad state”. Today’s article discusses some ways we can prune the “graph” of objects that we create in OOP so that there’s less state to maintain. Read on for some interesting techniques that could help you prevent bugs!
Say we have a player with a wallet that contains coins. Here’s how the public interface might look:
public interface IWallet { int Coins { get; } } public interface IPlayer { IWallet Wallet { get; } void BuyCoins(); }
This might be implemented internally like this:
internal interface IInternalWallet : IWallet { void SetCoins(int num); } internal class Wallet : IInternalWallet { public void SetCoins(int num) { Coins = num; } public int Coins { get; private set; } public Wallet(int coins) { Coins = coins; } } internal class Player : IPlayer { public IWallet Wallet { get; private set; } public Player() { Wallet = new Wallet(PlayerPrefs.GetInt("Coins")); } public void BuyCoins() { PlayerPrefs.SetInt("Coins", 100); Wallet.SetCoins(100); } }
So in memory we have a Player
object and it has a reference to a Wallet
object. Whenever the number of coins changes, the Player
has to inform the Wallet
that the value has changed.
But the number of coins is stored in the PlayerPrefs
database, so we’re really just keeping RAM in sync with the on-disk database. Instead, we could query the database on-demand. With this in mind we can rewrite the code like this:
internal interface IInternalWallet : IWallet { } internal class Wallet : IInternalWallet { public int Coins { get { return PlayerPrefs.GetInt("Coins"); } } } internal class Player : IPlayer { public IWallet Wallet { get; private set; } public Player() { Wallet = new Wallet(); } public void BuyCoins() { PlayerPrefs.SetInt("Coins", 100); } }
Ah! Much simpler! There’s no longer any in-memory coins value. Instead, the Coins
property fetches it from PlayerPrefs
whenever its get
is called. But why do we even bother having the Wallet
field of Player
around in memory when we no longer use it to synchronize? We can easily do away with it too:
internal class Player : IPlayer { public IWallet Wallet { get { return new Wallet(); } } public void BuyCoins() { PlayerPrefs.SetInt("Coins", 100); } }
Even simpler! In both cases we’ve replaced in-memory state with temporary objects, simplifying how much we have to make sure to keep in sync.
Now for some trouble. Say the wallet has an event for when its coins change:
public interface IWallet { int Coins { get; } event Action<int> OnCoinsChanged; }
Now when the player buys coins we don’t have a Wallet
to tell to dispatch the OnCoinsChanged
event. We could “solve” this by undoing our gains and reintroducing all the state, but let’s think of another way around it.
One key realization is that when you’re adding or removing listeners to an event you can override what happens, just like get
and set
for properties. This makes it possible for OnCoinsChanged
to forward the listeners added and removed from it to the Player
. We can do this by introducing some more events:
internal interface IInternalWallet : IWallet { event Action<Action<int>> OnCoinsChangedListenerAdded; event Action<Action<int>> OnCoinsChangedListenerRemoved; }
These events get dispatched from the add
and remove
blocks like this:
internal class Wallet : IInternalWallet { public event Action<int> OnCoinsChanged { add { OnCoinsChangedListenerAdded(value); } remove { OnCoinsChangedListenerRemoved(value); } } public event Action<Action<int>> OnCoinsChangedListenerAdded; public event Action<Action<int>> OnCoinsChangedListenerRemoved; public int Coins { get { return PlayerPrefs.GetInt("Coins"); } } }
When listeners are added or removed, the listener is passed to the OnCoinsChangedListenerAdded
and OnCoinsChangedListenerRemoved
events. Now Player
can listen for these events:
internal class Player : IPlayer { private event Action<int> OnCoinsChanged; public IWallet Wallet { get { var wallet = new Wallet(); wallet.OnCoinsChangedListenerAdded += a => OnCoinsChanged += a; wallet.OnCoinsChangedListenerRemoved += a => OnCoinsChanged -= a; return wallet; } } public void BuyCoins() { PlayerPrefs.SetInt("Coins", 100); if (OnCoinsChanged != null) { OnCoinsChanged(100); } } }
Notice how Player
has its own private OnCoinsChanged
event. The listeners it gets from Wallet
are added here. This means that it can dispatch its own OnCoinsChanged
event in BuyCoins
and all the listeners that were added via the event in Wallet
are called!
Finally, we need to make sure to clean up after ourselves. We create a new Wallet
every time someone calls the Wallet
getter but all of their listeners stick around on the Player
in its OnCoinsChanged
event unless explicitly removed. This is an error-prone situation since it’s highly likely that someone will forget to remove their listener from the Wallet
at some point. It would be much better if the listener removed itself when the (temporary) Wallet
object was garbage collected.
We can work around this too by adding another event:
internal interface IInternalWallet : IWallet { event Action<Action<int>> OnCoinsChangedListenerAdded; event Action<Action<int>> OnCoinsChangedListenerRemoved; event Action OnDestroyed; }
The OnDestroyed
simply tells the Player
that the wallet has been garbage collected. It’s easy to do this via a destructor: ~Wallet
internal class Wallet : IInternalWallet { public event Action<int> OnCoinsChanged { add { OnCoinsChangedListenerAdded(value); } remove { OnCoinsChangedListenerRemoved(value); } } public event Action<Action<int>> OnCoinsChangedListenerAdded; public event Action<Action<int>> OnCoinsChangedListenerRemoved; public event Action OnDestroyed; public int Coins { get { return PlayerPrefs.GetInt("Coins"); } } ~Wallet() { OnDestroyed(); } }
Now Player
can keep track of the listeners and remove them all when it gets the OnDestroyed
event:
internal class Player : IPlayer { private event Action<int> OnCoinsChanged; public IWallet Wallet { get { var listeners = new List<Action<int>>(); var wallet = new Wallet(); wallet.OnCoinsChangedListenerAdded += a => { OnCoinsChanged += a; listeners.Add(a); }; wallet.OnCoinsChangedListenerRemoved += a => { OnCoinsChanged -= a; listeners.Remove(a); }; wallet.OnDestroyed += () => { foreach (var act in listeners) { OnCoinsChanged -= act; } }; return wallet; } } public void BuyCoins() { PlayerPrefs.SetInt("Coins", 100); if (OnCoinsChanged != null) { OnCoinsChanged(100); } } }
Finally, let’s see all of this together with a little test MonoBehaviour
script that shows how to use it and proves that everything gets cleaned up.
using System; using System.Collections.Generic; using UnityEngine; static class Flag { public static int Value; } public interface IWallet { int Coins { get; } event Action<int> OnCoinsChanged; } public interface IPlayer { IWallet Wallet { get; } void BuyCoins(); int GetNumListeners(); } internal interface IInternalWallet : IWallet { event Action<Action<int>> OnCoinsChangedListenerAdded; event Action<Action<int>> OnCoinsChangedListenerRemoved; event Action OnDestroyed; } internal class Wallet : IInternalWallet { public event Action<int> OnCoinsChanged { add { OnCoinsChangedListenerAdded(value); } remove { OnCoinsChangedListenerRemoved(value); } } public event Action<Action<int>> OnCoinsChangedListenerAdded; public event Action<Action<int>> OnCoinsChangedListenerRemoved; public event Action OnDestroyed; public int Coins { get { return PlayerPrefs.GetInt("Coins"); } } ~Wallet() { OnDestroyed(); Flag.Value++; } } internal class Player : IPlayer { private event Action<int> OnCoinsChanged; public IWallet Wallet { get { var listeners = new List<Action<int>>(); var wallet = new Wallet(); wallet.OnCoinsChangedListenerAdded += a => { OnCoinsChanged += a; listeners.Add(a); }; wallet.OnCoinsChangedListenerRemoved += a => { OnCoinsChanged -= a; listeners.Remove(a); }; wallet.OnDestroyed += () => { foreach (var act in listeners) { OnCoinsChanged -= act; } }; return wallet; } } public void BuyCoins() { PlayerPrefs.SetInt("Coins", 100); if (OnCoinsChanged != null) { OnCoinsChanged(100); } } public int GetNumListeners() { return OnCoinsChanged == null ? 0 : OnCoinsChanged.GetInvocationList().Length; } } public class TestScript : MonoBehaviour { IPlayer player; void Start() { player = new Player(); Debug.Log("Num listeners initially: " + player.GetNumListeners()); player.Wallet.OnCoinsChanged += val => Debug.Log("Coins changed to: " + val); Debug.Log("Num listeners before buying: " + player.GetNumListeners()); player.BuyCoins(); Debug.Log("Num listeners after buying: " + player.GetNumListeners()); } void Update() { if (Flag.Value == 1) { Flag.Value++; Debug.Log("Num listeners after destroyed: " + player.GetNumListeners()); Debug.Log("Buying again..."); player.BuyCoins(); Debug.Log("^^^ nothing should have printed ^^^"); Debug.Log("Player's wallet has " + player.Wallet.Coins + " coins"); Debug.Log("Num listeners at end: " + player.GetNumListeners()); } } }
This prints:
Num listeners initially: 0 Num listeners before buying: 1 Coins changed to: 100 Num listeners after buying: 1 Num listeners after destroyed: 0 Buying again... ^^^ nothing should have printed ^^^ Player's wallet has 100 coins Num listeners at end: 0
As the user it looks like the player’s wallet is a permanent fixture of the Player
, but it’s actually getting created on-demand rather than being part of the object graph state. We can add event listeners to any of these temporary wallets and everything gets automatically cleaned up by the garbage collector when we remove all our references. Implementing this technique is even pretty straightforward as it’s mostly contained in the Wallet
getter and a few extra event declarations.
Hopefully this will help some of you prune your object graph and reduce the number of bugs related to state. Let me know in the comments if you’ve got any ideas related to this technique!
#1 by rocksoccer on September 1st, 2016 ·
In most cases, the reason for developers to introduce in memory state is to improve the performance, in other words, caching. Some of them are valid, and actually many of them are not, just premature optimization.
I think it is usually better to keep the caching mechanism in a separate layer in an application so it can be switched on or off depending on different requirements, like development or live version. This is very useful when trying to solve bugs.
In this case, if the performance bottleneck is to read from database, then probably a database caching class should wrap the database operations.
#2 by jackson on September 1st, 2016 ·
Very insightful! I actually came to this same realization about a month ago in some work I was doing. I realized that the in-memory state was essentially a memory cache of the database, but needed to be implemented and synchronized all through the app. It’d be much better to cache at a generic level rather than in each place that uses the database. For example, in the article the “database” is
PlayerPrefs
which could be easily cached with aDictionary
. This realization ultimately spawned this article. :)#3 by ms on February 22nd, 2017 ·
i’m not knowledgeable on the subject and my understanding weak but i’ve read that using a destructor/finalizer can cause objects waiting to be collected to inadvertently hang around longer, waiting for the next full gc cycle (generation 2?) — i’ve always avoided using this pattern because of these implications.
thoughts?
thanks,
m
#4 by jackson on February 22nd, 2017 ·
I haven’t heard this, but that doesn’t mean it’s not true. All I can say is that the destructor was called very quickly in the test I ran for the article. Full testing in various environments (e.g. IL2CPP and on target devices) is definitely warranted if you want to go with a strategy like the one described in the article.