Partially emulating #ifdef in Go with build tags and consts

December 14, 2023

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.

Written on 14 December 2023.
« Why systemd-resolved can give weird results for nonexistent bare hostnames
What /.well-known/ URL queries people make against our web servers »

Page tools: View Source.
Search:
Login: Password:

Last modified: Thu Dec 14 23:20:12 2023
This dinky wiki is brought to you by the Insane Hackers Guild, Python sub-branch.