Avoid the Preprocessor : Use ''Compile-Time Polymorphism'' for Cross-platform Development
By Steve Schnepp on Friday, 11 December 2009, 23:00 - c++ - Permalink
When writing portable cross-platform code, don't litter your code with preprocessor macros, use compile-time polymorphism instead.
A flexible build system will enable you to use advanced OOP-like compile-time polymorphism. That way you can hide all the specifics of the different platform behind an interface firewall. It is the usual way that most cross-platform toolkits and frameworks (such as QT, GTK or wxWidgets) are designed.
We'll take a very simple file I/O subsystem here as an example
(open, close). We'll also only take Linux and
Windows, since that's usually the 2 most common platforms people want to
develop for[1].
The usual abstraction with the preprocessor
Usually preprocessor #ifdefs are used to compile specific parts
for the underlying OS.
The file fileiosys.cpp looks[2] like the one below
:
fileiosys_filedesc open(char* filename) {
#ifdefine __WIN32__
return OpenFile(
filename,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
);
#else // UNIX
return open(filename, O_READ | O_WRITE, 0666);
#endif
}
int close (fileiosys_filedesc fd) {
#ifdefine __WIN32__
return CloseHandle(fd) ? 0 : -1;
#else // UNIX
return close(fd);
#endif
}
The header fileiosys.h looks then like :
// Define a custom file descriptor
#ifdefine __WIN32__
#define fileiosys_filedesc HANDLE
#else // UNIX
#define fileiosys_filedesc int
#endif
fileiosys_filedesc open(char* filename);
int close(fileiosys_filedesc fd);
Avoid the preprocessor Compile-time polymorphism
As the previous little example, the code isn't really easy to read. You have to always think which environnement you are in. All the specifics are multiplexed in the same file, and the programmer has to always demultiplex it in real time each time he reads the code.
The last part of the interface file is quite simple to read since it's already abstracted away. Now let's completely demultiplex the implementation in several implementation files.
The header file is the common interface
The interface is the most important part of the design. It should be high-level enough to mask the differences between the plateforms you want to support, but not too high-level, otherwise you'll end up duplicating to much code.
So, for our file I/O subsystem, we'll just abstract the usual syscalls
open, close in the same way as before.
The parameters that are passed through the interface are also very important. You cannot usually leak a platform-specific structure. So here all the file descriptors are just an opaque handle, represented by a pointer to a structure defined only as a forward declaration in the header. This pattern, sometimes called the pimpl idiom, enables use to really share the representation while implementing it differently.
The header file is preserved as fileiosys.h, whereas the
implementation is in the files linux/fileiosys.cpp and
win32/fileiosys.cpp
fileiosys.h
// Define the forward declaration struct fis_filedesc; fis_filedesc* open(char* filename); int close(fileiosys_filedesc* fd);
win32/fileiosys.cpp
struct fis_filedesc {
HANDLE handle;
};
fis_filedesc* open(char* filename) {
fis_filedesc* fd = new fis_filedesc();
fd->handle = OpenFile(
filename,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
);
return fd;
}
int close (fis_filedesc* fd) {
int retval = CloseHandle(fd->handle) ? 0 : -1;
delete(fd);
return retval;
}
linux/fileiosys.cpp
struct fis_filedesc {
int file_descriptor;
};
static fis_filedesc[32];
fis_filedesc* open(char* filename) {
fis_filedesc* fd = new fis_filedesc();
fd->file_descriptor = open(filename, O_READ | O_WRITE, 0666);
return fd;
}
int close (fis_filedesc* fd) {
int retval = close(fd);
delete(fd);
return retval;
}
The build system : Makefile
The makefile should take into account the different platforms, and only compile the needed implementation file. All the gluing magic will then be done at linking time instead of preprocessor time.
Conclusion
The interest of have multiple implementation files is obvious. It is much more straightforward to read and only marginally harder to write. But since most of the time code is read and not written, the choose is quite a no-brainer.
The nicest part is that all this is possible even without the expensive run-time polymorphism and RTTI, since the choose is done at compile-time.