Today I started working on hot reloading support for my SC2 bot, so I can modify the code and automatically reload the modified DLL without closing the game. This required changing the architecture from a monolothic build (all statically linked to one application) to a multi-DLL setup, where each DLL exports their symbols.
As always, start with the definitions ...
#if defined(MONOLITHIC)
#define GAMEBASE_API
#elif defined(GAMEBASE_EXPORTS)
#define GAMEBASE_API __declspec(dllexport)
#else
#define GAMEBASE_API __declspec(dllimport)
#endif
So far I can add this convenient GAMEBASE_API
in front of functions or class declarations, and the symbols are automatically exported / imported. Unfortunately, soon an unresolved external linker error appeared and persisted:
struct GAMEBASE_API AbilCmd : public GcHandleBase, public Nullable<AbilCmd>{...}
It insists it can't find AbilCmd::Null
- error LNK2001: unresolved external symbol "public: static class AbilCmd const Nullable
To explain a bit why I initially chose a design involving CRTP base, there are a bunch of similar types (such as Unit, Point, Region ...) that all have a "Null" sentinel value, but each null is strongly typed so it can be used for validation or as a default value. These classes may implement specific equality-comparison logic, e.g. Unit
s compare their handle id whereas Text
s would compare the content. Implementing the null check as a comparison with its strongly typed, possibly constexpr
Null
value unifies the logic with the normal comparison operators, and avoids repeating the code.
template <class T> struct Nullable
{
// Private ctor and friend to ensure CRTP
private:
Nullable() {}
friend T;
public:
static const T Null;
bool isNull() const { return static_cast<const T&>(*this) == Null; }
explicit operator bool() const { return !isNull(); }
};
template<class T> const T Nullable<T>::Null;
This worked perfectly fine in monolithic mode. Now the problem is that it breaks as soon as we attempt to export the derived class, struct GAMEBASE_API AbilCmd
, because the static member AbilCmd::Null
is actually a part of the CRTP base, but putting dllexport to the base would require it to have dll linkage.
The problem must have existed for a long time - I was able to find similar SO answers such as https://stackoverflow.com/questions/7848865/dll-exporting-static-members-of-template-base-class. After some experiments, I have to say I really dislike the combined usage of macros and explicit template instantiation, which almost defeated the purpose of implementing a CRTP base, because it would introduce more syntactic noise than it removes.
Specializing the CRTP base for each exported class would work, but it would require exporting the CRTP base class and therefore it requires specializing ALL its members rather than just the static member.
In the end I came up with the following code snippet:
#define IMPL_NULLABLE(TYPE) \
template<> bool \
Dream::Nullable<TYPE>::isNull() const \
{ \
return crtp_cast<TYPE>(*this) == Null; \
} \
template<> \
Dream::Nullable<TYPE>::operator bool() const \
{ \
return !isNull(); \
} \
template<> \
const TYPE Dream::Nullable<TYPE>::Null
Placing the macro into each cpp for every exported class would work. It's still ugly, but hopefully easy enough to understand.
But can we do better? Yes, by putting back the declaration of Null
back into each class and implement the static in their .cc
respectively. Without the static member, the CRTP base can still be used without dllexport/import. Better yet, don't use CRTP at all and put the explicit operator bool()
into each class. It is some work, but the work is finite, can be accelerated with tools, and it's (amortized) one-time effort.
when I reconsider the choice I made long ago - it's bad. I must have been damn excited about my newly learnt knowledge about CRTP , so I designed the strongly-typed Null, strongly-typed comparison and all possible things around it. In the end, I got more annoyances than gains, as some function calls became like:
UnitGroupFilterUnitTypes(UnitGroup(UnitGameLink::Null, playerId, Region::Null, filter)
. It's hard to write, but also does not benefit the reader (The naming of the function and the parameters are more important than the look of the function argument).
The strongly-typed Null
is also in no way safer than a magic constant of special type such as std::nullptr
or std::nullopt
. In order to make a distinction to the pointer-alike nullptr
, here we can easily implement a custom neutral value as constexpr struct nullhandle_t {} nullhandle;
and use that for constructors / comparison operators / default values. I'd even say the strongly-typed Null
counts as one of the things that wouldn't matter in a thousand years.
I must remind myself to refrain from the tendency of minimizing the code length - sometimes the brevity aligns with simplicity, but more often than not they are very different.