C++ Objectified Arguments
Last year, I started experimenting with strong types for C++ arguments. After a year of working trying various approaches, I have found the sweet spot for creating APIs that are
- strongly typed,
- easy to use,
- and compile efficiently.
Here is a quick reminder of what a strongly typed API looks like.
// see the strong type argument post linked above for how to define Argument
using SourceFilePath = Argument<const char *, struct SourceFileTag>;
using DestinationFilePath = Argument<const char *, struct DestinationFilePathTag>;
int copy_file_weak(
const char * source,
const char * destination
);
int copy_file_strong(
SourceFilePath source,
DestinationFilePath destination
);
//then calling method looks like this
//order of the argument is obvious
copy_file_strong(
SourceFilePath("/home/a.txt"),
DestinationFilePath("/home/b.txt")
);
//hard to know which is the source/dest without checking the declartion
copy_file_weak(
"/home/a.txt",
"/home/b.txt"
);
The example above illustrates some of the pros and cons of using strongly typed arguments.
- Code is super easy to read 👍
- Argument order is obvious when then looking at the implementation 👍
- APIs are cumbersome to code 👎
An alternative approach is to define a lightweight class that packages up the arguments and provides setters and getters to access arguments. Consider the following example:
class CopyOptions {
public:
CopyOptions& set_source(const char * value){
m_source = value;
return *this;
}
CopyOptions& set_destination(const char * value){
m_destination = value;
return *this;
}
const char * source() const {
return m_source;
}
const char * destination() const {
return m_destination;
}
private:
const char * m_source = nullptr;
const char * m_destination = nullptr; //set to the default
};
static void copy_file_strong(const CopyOptions & options);
static void copy_file_weak(const char * source, const char * destination);
//strong call
//very obvious what the arguments are
//added bonus -- arguments an be in any order
//default argument values can be define in CopyOptions
copy_file_strong(
CopyOptions()
.set_source("/home/a.txt")
.set_destination("/home/b.txt")
);
//same problems as before
copy_file_weak(
"/home/a.txt",
"/home/b.txt"
);
On the implementation side, this approach works much better. Code completion is especially nice because it picks up right away what options can be set. 👍
But we introduced a pretty cumbersome problem with the setters and getters 👎. To solve that we create a getter/setter macro that looks something like this.
#define API_ACCESS_FUNDAMENTAL(c, t, v, iv) \
public: \
t v() const { return m_##v; } \
c& set_##v(t value){ m_##v = value; return *this; } \
private: \
t m_##v = iv
class CopyOptions {
API_ACCESS_FUNDAMENTAL(CopyOptions,const char *,source,nullptr);
API_ACCESS_FUNDAMENTAL(CopyOptions,const char *,destination,nullptr);
};
Now that is much better.
So the next question is whether we are still efficient (❓). To get an idea of the performance hit from this approach, I compiled a simple HelloWorld
program (cross-compiled to the ARM Cortex M architecture) and checked the binary output size.
#include <cstdio>
#include <sapi/api/ApiObject.hpp>
#define PASS_BY_OBJECT 1
#if PASS_BY_OBJECT
class ExecuteOptions {
API_ACCESS_FUNDAMENTAL(ExecuteOptions,const char *,hello,nullptr);
API_ACCESS_FUNDAMENTAL(ExecuteOptions,const char *,world,nullptr);
};
static void execute(const ExecuteOptions & options);
#else
static void execute(const char * hello, const char * world);
#endif
int main(int argc, char * argv[]){
#if PASS_BY_OBJECT
execute(
ExecuteOptions()
.set_hello("Hello")
.set_world("World")
);
#else
execute("Hello", "World");
#endif
return 0;
}
#if PASS_BY_OBJECT
void execute(const ExecuteOptions & options){
printf("%s %s\n", options.hello(), options.world());
}
#else
void execute(const char * hello, const char * world){
printf("%s %s\n", hello, world);
}
#endif
To my surprise, the programs were compiled to the same size (with optimization on, of course).
PASS_BY_OBJECT=1
code size: 548PASS_BY_OBJECT=0
code size: 548
This example is particularly simple. If you are highly concerned about performance and code size, you will want to experiment. Nonetheless, this approach is a great way to write complex APIs with clear code that enforces strong argument types.
Last thing, using the Argument
class for a strong bool
argument is still worthwhile. Consider this code:
remove(true); //hmmm, what is true? what does that mean?
remove(IsRecursive(true)); //much better, recursive remove
//header looks like this
using IsRecursive = Argument<bool, struct IsRecursiveTag>;
int remove(IsRecursive is_recursive);
There are some other places where Argument
works well, but if it isn’t used sparingly, the code becomes quite cumbersome.