With few exceptions, software engineers write code, not for themselves, but for future maintainers of their code; and as anyone who’s lost their glasses can attest, this sensibility can be productively applied even if those future maintainers are literally the same people who wrote the code!
Interface designers can contribute to future maintainability of code by designing interfaces whose calls are self-documenting: the maintenance developer can tell what a function call is doing by inspection.
The C runtime has some interfaces that are intended to make for readable calls:
memcpy( dst, src, size); // copy from src to dst (echoes assignment operator)
atan2( y, x ); // echoes arctangent of y/x
I got an early introduction to the value of self-documenting interfaces while porting the flagship Softimage application. Whenever the scene database for the application was updated – a new object created, or the user enabled wireframe mode for a window – an internal function called SI_Refresh() would be called. This function would refresh the user’s view to reflect the updates to the scene database. To optimize its performance, the caller could specify flags as to what aspects of the scene database had been updated, and those were Booleans.
Everyone who’s built software knows how that interface grew: organically, with an innocuous-seeming Boolean parameter added here, then there, then… By the time I started work on the Softimage port, the function had bloated to a dozen or more Boolean parameters, and invocations of the function looked like this:
SI_Refresh( ctx, TRUE, FALSE, FALSE, FALSE, TRUE, … );
Each Boolean parameter corresponded to some specific aspect of the scene database; and developers who’d lived with the code base for some time certainly knew what those parameters meant, but I certainly didn’t.
I did a bit of research with find
and grep and related tools, and discovered that across 650,000 lines of code, there were most of 1,000 invocations of SI_Refresh(), scattered throughout the renderer, the modeler, and other functional divisions of code.
The situation cried out for a refactoring: there were nowhere near 32 Boolean parameters to SI_Refresh(), so even a 32-bit word would be a future-proof way to replace that gaggle of Booleans with readable flags. Think:
SI_Refresh( ctx, SI_REFRESH_WIREFRAME);
With a bit more research, I could histogram the most common values specified to these Boolean parameters, and design flags intended to maximize readability of the SI_Refresh() calls. A comprehensive search-and-replace of the code base then would be needed, possibly with an interim strategy, like implementing the ‘legacy’ SI_Refresh() function as an inline invocation of the new SI_Refresh() function, constructing the flags word by examining the old Boolean parameters.
There wouldn’t even be a performance hit: if anything, the new interface would be more efficient, especially on RISC processors whose ABIs relied on registers to pass parameters (Windows NT still ran on MIPS and Alpha at the time). Note, SI_Refresh() was not the kind of function where the overhead of calling it could make for a credible argument for or against the proposed interface change based on performance; but we’d had enough silly arguments about performance with the Montreal team that I knew it would come up.
Sadly, this tale doesn’t end with a heroic refactoring of the code base, delivered on a silver platter from the intrepid Redmond-based porting team. We fixed more than 900 bugs during the port, but it wasn’t really our job to refactor the code base. I was ready to undertake the effort, when my studies of existing calls to SI_Refresh() yielded a disconcerting insight: someone on the rendering team already had implemented a refactoring along these lines, replacing all calls to SI_Refresh() with a macro, complete with flags words, that expanded to the less-readable call with the series of Boolean parameters.
The reason the change was limited to the rendering subsystem was a byproduct of Softimage’s engineering practices: they strictly limited the parts of a code base that any given developer could contribute to. Our porting team in Redmond was not subject to this restriction, but in Montreal, it was enforced with access restrictions! So the developer who’d devised and implemented the refactoring to make SI_Refresh() calls more readable, was not able to deliver the benefits of that change to the whole application, only to the part they were allowed to modify.
Flags words, of course, aren’t the only way to make interface usage more readable. Long parameter lists make for less-readable code, even when the parameters themselves are more strongly typed than a series of Booleans. For such cases, I’ve found structures to be a handy replacement for long parameter lists. My first exposure to this interface design methodology was Win32’s CreateFont() function:
HFONT CreateFontA(
[in] int cHeight,
[in] int cWidth,
[in] int cEscapement,
[in] int cOrientation,
[in] int cWeight,
[in] DWORD bItalic,
[in] DWORD bUnderline,
[in] DWORD bStrikeOut,
[in] DWORD iCharSet,
[in] DWORD iOutPrecision,
[in] DWORD iClipPrecision,
[in] DWORD iQuality,
[in] DWORD iPitchAndFamily,
[in] LPCSTR pszFaceName
);
Contrast with CreateFontIndirect(), its struct-based equivalent:
HFONT CreateFontIndirectA(
[in] const LOGFONTA *lplf
);
typedef struct tagLOGFONTA {
LONG lfHeight;
LONG lfWidth;
LONG lfEscapement;
LONG lfOrientation;
LONG lfWeight;
BYTE lfItalic;
BYTE lfUnderline;
BYTE lfStrikeOut;
BYTE lfCharSet;
BYTE lfOutPrecision;
BYTE lfClipPrecision;
BYTE lfQuality;
BYTE lfPitchAndFamily;
CHAR lfFaceName[LF_FACESIZE];
} LOGFONTA, *PLOGFONTA, *NPLOGFONTA, *LPLOGFONTA;
I borrowed a page from this playbook when designing CUDA’s memcpy functions, but took it a step further: CUDA’s 3D memcpy structure is an example of an interface designed, not just for readability, but usability: it can be zero-initialized per 1970s-era K&R C, and the interface is designed to specify reasonable defaults when 0’s are encountered in the structure. That way, simple invocations of cuMemcpy3D() look simple, and complicated ones look complicated.
Added benefits of structure-based interfaces are that the caller can conveniently save the structures for reuse, and as of C99, structure members may be set with designated initializers (i.e. by name) rather than in order, further aiding readability of code that uses the interface.
The main downside to using a structure as part of an interface definition is language-specified ambiguity as to the exact size and layout of the structure. A structure such as the following:
struct MyStruct {
char c1;
int i;
char c2;
};
May occupy six (6) bytes, or twelve (12) bytes (assuming 32-bit int
), if the compiler decides to pad the structure so i
is aligned. Such compiler behavior may even be controlled by compiler flags and/or language extensions such as __attribute(packed))
.
In my experience, these risks can be mitigated by avoiding the known pitfalls where compilers for various platforms are bound to disagree:
structure members that invite the compiler to introduce padding (e.g. smaller than 32-bits);
bitfields, and
embedded structures.
For even better readability, interface designs tend to become language-specific: If you know your clients will be using C++, you can embrace patterns such as the factory functions used to construct PyTorch Tensor Options, that can be chained in any order to build up the needed parameter:
torch::TensorOptions options = torch::TensorOptions()
.dtype(torch::kFloat64) // Set data type to double
.device(torch::kCPU) // Set device to CPU
.requires_grad(true); // Enable gradient computation
For my part, I usually leave that type of ergonomic improvement to the specific language bindings of the language-agnostic interfaces that I tend to define.
Zero as a default value is such an underrated technique. Vulkan is the perfect example of how not to do this. Oh, you forgot to set literally every single field in a struct? Congrats, your screen is black now.