🎉 Celebrating 25 Years of GameDev.net! 🎉

Not many can claim 25 years on the Internet! Join us in celebrating this milestone. Learn more about our history, and thank you for being a part of our community!

Rookie OOP question: How to avoid having to pass tons of inputs everywhere

Started by
15 comments, last by hplus0603 3 years, 12 months ago

One approach is to put your important subsystems behind interface classes, and have one struct that contains a pointer to each. This lets you just pass around “the environment” (or “the world”) to the other systems, without having to change 32 different prototypes everytime you add another subsystem.

This is generally better than using singletons everywhere, because you can enforce ordering, and there is no “global” state to leak between unit tests – you can create a new, fresh environment for each unit test. You can also run systems in parallel in multiple threads without them stepping on each other.

Finally, because you wrap the various subsystems into a virtual interface, you can swap out fake/temporary systems for files, networking, rendering, databases, and anything else that would normally interact with physical I/O. This makes it easier to write robust unit tests for things that “need to read a file” without having to wait for disk.

Example (sketch):

class IService {
public:
  virtual void startEnv(Environment *env) = 0;
};

class IFilesystem : public IService {
public:
  virtual future<shared_ptr<IBlob>> readFile(string const &path) = 0;
  virtual future<Status> writeFile(string const &path, sharer_ptr<IBlob> const &data) = 0;
  virtual future<vector<string>> listDirectory(string const &path) = 0;
};

class IClock {
public:
  virtual Timestamp now() = 0;
};
...

struct Environment {
  IFilesystem *filesystem = nullptr;
  IClock *clock = nullptr;
  ...
  
  void startAll() {
    filesystem->startEnv(this);
    clock->startEnv(this);
    ...
  }
};

You'll then have concrete implementations for the interfaces, that you set up in the beginning.

class CRealClock : public IClock {
public:
  Timestamp now() override { return read_the_physical_clock(); }
  void startEnv(Environment *env) override {}
};

class CFakeClock : public IClock {
public:
  Timestamp now() override { testTime.increment(1); return testTime; }
  void startEnv(Environment *env) override {}
  Timestamp testTime; // poke this in unit test runners to advance time
};

...
Environment *env = new Environment();
env->filesystem = new CMemoryFilesystem();
env->clock = new CFakeClock();
env->startAll();
useThisEnvironmentInATest(env);

When you make an Actor or ParticleSystem or whatever, you simply make sure to pass the Environment to it, and it can get what it needs from there. The specific interfaces you choose to expose in the Environment varies by your engine – do you support “files” or “assets" or “blobs” for reading things? Do you have a “renderer” or a “scene graph” or an “object registry?" And on and on.

If you're going with an object oriented design, this is a good way of dealing with “basic subsystems” in a way that avoids the pitfalls of singletons, and avoids having to pass 18 separate pointers to the constructor of each other new subsystem.

enum Bool { True, False, FileNotFound };
Advertisement

I think Singleton gets a bad name. It's totally fine to use it, just don't overdo it. I use singletons for things like:

  • TextureManager, ResourceManager, <X>Manager, etc..
  • Game/GraphicsSettings

Maybe it's bad in super large code bases, I dunno.

There are really only a few issues to worry about with these, and they are often subtle for beginners.

Mutable global state is bad generally. If a value can be changed anywhere, by any system, at any time, you'll have bugs where something changed the state and you have no idea how, when, where, or why. You can have objects that are mutable under certain rules, and those rules are enforced by policy, and they tend to work out most of the problems. For example, you may decide that while objects can be flagged for destruction at any time, nothing is actually destroyed until after the end of the simulation loop; the work is queued and delayed so that the simulation is not actively running at the time.

Singletons enforce a rule that there can only be one item, ever, and there can never be any other instances, nor any derivatives, nor specializations. There can be only one. This comes with a TON of problems. However, it can be contrasted with a well-known instance, an instance of an object which happens to be globally accessible. This well-known instance can be a pointer to an object which could be replaced with a subclass, derivative, or specialization. Other systems could create their own instances which they use for their own purposes, if they want. While the well-known instance becomes a hidden dependency with it's own set of problems, they tend to be a far better solution than a singleton that enforces strict rules about being the only one ever.

Some examples of a well-known instance are the standard IO streams, like cin and cout. You can create your own IO streams, you can even attach other IO streams to the main console if you like. But those specific, well-known instances are available for your use, and should be used carefully within your program if you choose to use them.

Dependency injection is a common solution to this, where you tell each object what they're supposed to be using. SetRenderer(myRenderer); SetMusicPlayer(myAudioSystem); or whatever you want to call it Then the instance knows what to use.

A hybrid solution allows for both. A somewhat common hybrid solution is a well-known object with pointers to the active items, such as a pointer to the renderer, a pointer to the physics engine, a pointer to the simulation, a pointer to the audio system, etc. Each subsystem can also accept dependency injection in the instance, they can have their various tools set. Then when they go to use it, the function can first probe for the injected version, and if it isn't there, use the well known instance, such as:

Renderer* GetRenderer() { return pAssignedRenderer ? pAssignedRenderer : ::globals::gRenderer; }

It isn't as good as passing a bundle around, but in some situations — especially situations with legacy code — it can be a good workaround.

totesmagotes said:

I think Singleton gets a bad name.

It very deservedly get a bad name. Singletons are just globals. The fact that you access them through a function rather than a direct global reference doesn't really change anything.

Singletons are terrible for testing. Suppose you want to test that loading a level loads all textures. You could write a fake texture factory, and pass that to the level loader, and sense which textures were “loaded” without having to actually touch the disk or the graphics card. But if the singleton factory happens to already have manufactured a real texture factory, now you're screwed. You have to run each test in its own invocation of the game binary, which adds a lot of overhead runtime to your tests. Automated tests should run in seconds, not minutes.

frob said:

A hybrid solution allows for both.

If you try to work around this by having a function that can “set” the factory, and then later “set it back,” then instead you now have a threading problem – you can't run tests in parallel in your binary. If you have a 8-core machine, you just increased the runtime of your test suite by 8x. If you have a Threadripper, you'll cry frustrated tears of running 32x slower than you could be.

But maybe you don't care about automated testing? Maybe you leave bugs to be found by yourself, or testers, or players, using human sweat hours instead of CPU seconds?

Singletons are still bad from a design point of view, because you can't, for example, spin up a client and a server instance of your game logic for a user-hosted game. Instead, your factories grow all kinds of state, like “am I being called from a server context, then manufacture a fake texture instance to avoid crashing whatever it was that called me." Which ends up with if() statements that you will get wrong in hilarious, frustrating, and downright scary cases, some of which you won't even find before users find them, because you don't believe in automated testing.

Singletons: Been there, done that, not doing it again!

Really. Pass along a context/world/factoryglob. It's so much better, and it's easy to implement.

enum Bool { True, False, FileNotFound };

This topic is closed to new replies.

Advertisement