2023-12-14
Partially emulating #ifdef in Go with build tags and consts
Recently on the Fediverse, Tim Bray wished for #ifdef in Go code:
I would really REALLY like to have #ifdef in this Go code I’m working on - there’s this fairly heavyweight debugging stuff that I regularly switch in to chase a particular class of problems, but don’t want active in production code. #ifdef would have exactly the right semantics. Yeah, I know about tags.
Thanks to modern compiler technology in the Go toolchain, we can sort of emulate #ifdef through the use of build tags combined with some other tricks. How well the emulation works depends on what you want to do; for some things it's almost perfect and for other things it's going to be at best awkward.
The basic idea is to take advantage of build tags combined with
dead code elimination (DCE). We'll use
tagged files to define a constant, say doMyDebug
, to either true
or false
:
$ cat ifdef-debug.go //go:build !myrelease package ... const doMyDebug = true $ cat ifdef-release.go //go:build myrelease package ... const doMyDebug = false
Now you can use 'if doMyDebug { .... }
' in your regular code as
a version of #ifdef. The magic of dead code elimination in Go will
remove all of your conditional debugging code if you build with a
'myrelease' tag and so define 'goMyDebug
' as false. Go's dead
code elimination is smart enough to eliminate not just the code
itself but also any data (such as strings) that's only used by that
code, any functions called only by that code (directly or indirectly),
any data used only by those functions, and so on (although none of
this can be exported from your package).
This works fine for one #ifdef equivalent. It works less fine if
you want a number of them, all controlled independently, because
then you need two little files per flag, which makes the clutter
add up fast. You can confine the mess by creating an internal package
to hold all of them, say 'internal/ifdef', and then importing it
in the rest of your code and using 'if ifdef.DoMyDebug { ... }
'
(the name has to be capitalized since now it has to be exported).
Where this starts to not work so well is if you want to do more
than put debugging code into functions. A semi-okay case is if you
want to keep some completely separate additional data (in separate
data structures) when debugging is turned on. Here your best bet
is probably to put all of the definitions and functions in your
conditionally built 'ifdef-debug.go
', with function stubs in
ifdef-release.go
, and call the necessary functions from your
regular code. You don't need to make these calls conditional; Go
is smart enough to inline and then erase function calls to empty
functions (or functions that in non-debugging mode return a constant
'all is okay' result, and then it will DCE the never-taken code
branch). This requires you to keep the stub versions of the functions
in sync with the real versions; the more such functions you have
the worst things are probably going to be.
Probably the worst case is if you want to conditionally augment
some of your data structures with extra fields that are only present
(and used) when debugging is defined on. If you can have the fields
always present but only touched when debugging is defined on, this
is relatively straightforward (since we can protect all the code
with 'if ...
' and then let DCE eliminate it all when debugging
is off). However, if you want the fields to be completely gone with
debugging off (so that they don't take up any memory and so on),
then life is at best rather awkward. In the straightforward version
you need to duplicate the definition of these structures in equivalents
of ifdef-debug.go
and ifdef-release.go
, and only access the
extra debugging fields through functions that are also in
ifdef-debug.go
(and stubbed out in ifdef-release.go
). This will
probably significantly distort your code structure and make things
harder to follow and more error prone.
A less aesthetic version of adding extra data to data structures only when debugging is on is to put all of the debugging data fields into a separate struct type, and then put an instance of the entire struct type in your main data structure. For example:
type myCoreType struct { [...] dd extraDebugData [...] }
The real definition of extraDebugData
and its fields is in your
ifdef-debug.go
file, along with the functions that manipulate it.
Your ifdef-release.go
stub file has an empty 'struct {}
'
definition of extraDebugData (and stub versions of all its functions).
Note that you don't want to put this extra data at the end of your
core struct, because a zero-sized field at the end of a struct
has a non-zero size.
It may also be more difficult to get a minimally-sized myCoreType
structure that doesn't have alignment holes with debugging on,
depending on what debugging fields you're adding. This still has
the disadvantage that you can't manipulate these extra debugging
fields in line with the rest of your code; you have to call out to
separate functions that can be stubbed out.
(The reason for this is that even though the code may never be executed, Go still requires it to be valid and to not do things like access struct fields that don't exist.)
A variation of this with extra memory overhead that allows for
inline code is to always define the real extraDebugData
struct
but use a pointer to it in myCoreType
. Then you can set the pointer
and manipulate its fields through regular code guarded by 'if
doMyDebug
' (or perhaps 'if doMyDebug && obj.dd != nil
'), and
have it all eliminated when doMyDebug
is constant false. This
creates a separate additional allocation for the extraDebugData
structure in debug mode and means your release builds have an extra
pointer field in myCoreType
that's always nil.
All of this only works with constants for the doMyDebug
names,
not with variables, which means you can't inject the 'ifdef' values
on the command line through the Go linker's
support for setting string variable values with -X. You have to use
build tags and constants in order to get the dead code elimination
that makes this more or less zero cost when you're building a release
version.
(I suggest that you make the default, no tags version of your code be the one with everything enabled and then specifically set build tags to remove things. I feel that this is more likely to play well with various code analysis tools and editors, because by default (with no tags set) they'll see full information about fields, types, functions, and so on.)
PS: There are probably other clever ways to do this.