Abstract
In this article, I will describe a technique that reduces coupling between an object that provides access to many types of objects ("the provider") and its users by moving compile-time dependencies to link-time. In doing so, we can reduce the amount of unnecessary compiling of the provider's dependencies whenever the provider is changed. I will then explore the benefits and costs to such a design.
The Problem
Typically in a game, a provider object is needed to expose a variety of services and objects to many parts of the game, much like a heart pumps blood throughout the body. This provider object is a sort of "context object", which is setup with the current state of the game and exposes other useful objects. Such a class could look something like listing 1, and an example of use could look something like listing 2.
//
// Listing 1:
// ServiceContext.h
#pragma once
// Dependencies
class Game;
class World;
class RenderService;
class ResourceService;
class PathfindingService;
class PhysicsService;
class LogService;
// ServiceContext
// Provides access to a variety of objects
class ServiceContext {
public:
Game* const game;
World* const world;
RenderService* const render;
PathfindingService* const path;
PhysicsService* const physics;
AudioService* const audio;
LogService* const log;
};
Listing 1: The definition of a sample context object
//
// Listing 2:
// Foobar.cpp
#include "Foobar.h"
#include "ServiceContext.h"
#include "PathService.h"
#include "LogService.h"
void Foobar::frobnicate( ServiceContext& ctx ) {
if ( !condition() )
return;
current_path = ctx.path->evaluate(position, target->position);
if ( !current_path )
ctx->log("Warning","No path found!");
}
Listing 2: An example usage of the sample context object
The
ServiceContext is the blood of the program, and many objects depend on it. If a new service is added to
ServiceContext or
ServiceContext is changed in any way, then all of its dependents will be recompiled, regardless if the dependent uses the new service.
See figure 1.
Figure 1: Recompilations needed when adding a service to the provider object
To reduce these unnecessary recompilations, we can use (abuse) the linker to hide the dependencies.
The Solution
We can hide the dependencies by moving compile-time dependencies to link-time dependencies. With templates, we can write a generic
get function and supply specialized definitions in its translation unit.
//
// Listing 3:
// ServiceContext.h
#pragma once
// Dependencies
struct ServiceContextImpl;
// ServiceContext
// Provides access to a variety of objects
class ServiceContext {
public: // Constructors
ServiceContext( ServiceContextImpl& p );
public: // Methods
template
T* get() const;
private: // Members
ServiceContextImpl& impl;
};
//
// ServiceContextImpl.h
#pragma once
// Dependencies
class Game;
class World;
class RenderService;
class ResourceService;
class PathfindingService;
class PhysicsService;
class LogService;
// ServiceContextImpl
// Exposes the objects to ServiceContext
// Be sure to update ServiceContext.cpp whenever this definition changes!
struct ServiceContextImpl {
Game* const game;
World* const world;
RenderService* const render;
PathfindingService* const path;
PhysicsService* const physics;
AudioService* const audio;
LogService* const log;
};
Listing 3: The declarations of the two new classes
//
// Listing 4:
// ServiceContext.cpp
#include "ServiceContext.h"
#include "ServiceContextImpl.h"
ServiceContext::ServiceContext( ServiceContextImpl& p ) : impl(p) {
}
// Expose impl by providing the specializations for ServiceContext::get
template<>
Game* ServiceContext::get() {
return impl.game;
}
// ... or use a macro
#define SERVICECONTEXT_GET( type, name ) \
template<> \
type* ServiceContext::get() const { \
return impl.name; \
}
SERVICECONTEXT_GET( World, world );
SERVICECONTEXT_GET( RenderService, render );
SERVICECONTEXT_GET( PathfindingService, path );
SERVICECONTEXT_GET( PhysicsService, physics );
SERVICECONTEXT_GET( AudioService, audio );
SERVICECONTEXT_GET( LogService, log );
Listing 4: The new ServiceContext definition
In listing 3, we have delegated the volatile definition of
ServiceContext to a new class,
ServiceContextImpl. In addition, we now have a generic
get member function which can generate member function
declarations for every type of service we wish to provide. In listing 4, we provide the
get definitions for every member of
ServiceContextImpl. The definitions are provided to the linker at link-time, which are then linked to the modules that use the
ServiceContext.
//
// Listing 5:
// Foobar.cpp
#pragma once
#include "Foobar.h"
#include "ServiceContext.h"
#include "PathService.h"
#include "LogService.h"
void Foobar::frobnicate( ServiceContext& ctx ) {
if ( !condition() )
return;
current_path = ctx.get()->evaluate(position, target->position);
if ( !current_path )
ctx.get()->log("Warning","No path found!");
}
Listing 5: The Foobar implementation using the new ServiceContext
With this design,
ServiceContext can remain unchanged and all changes to its implementation are only known to those objects that setup the
ServiceContext object. See figure 2.
Figure 2: Adding new services to ServiceContextImpl now has minimal impact on ServiceContext's dependants
When a new service is added,
Game and
ServiceContextImpl are recompiled into new modules, and the linker relinks dependencies with the new definitions. If all went well, this relinking should cost less than recompiling each dependency.
The Caveats
There are a few considerations to make before using this solution:
- The solution hinges on the linker's support for "whole program optimization" and can inline the ServiceContext's get definitions at link-time. If this optimization is supported, then there is no additional cost to using this solution over the traditional approach. MSVC and GCC have support for this optimization.
- It is assumed that ServiceContext is changed often during development, though usually during early development. It can be argued that such a complicated system is not needed after a few iterations of ServiceContext.
- It is assumed that the compiling time greatly outweighs linking time. This solution may not be appropriate for larger projects.
- The solution favors cleverness over readability. There is a increase in complexity with such a solution, and it could be argued that the complexity is not worth the marginal savings in compiling time. This solution may not be appropriate if the project has multiple developers.
- The solution does not offer any advantage in Unity builds.
Conclusion
While this solution does reduce unnecessary recompilations, it does add complexity to the project. Depending on the size of the project, the solution should grant a small to medium sized project with decreased compilation time.
I have run into this same issue in my code before, and tried to think of a good solution. I like your approach. I agree on a large project it may not have the most benefit. I worked on a game with a large team where the compiles were distributed, but linking was local which usually took a big chunk of time. On my personal project, though, this would work great. Will try it out. Thanks!