Compile-Time Polymorphism for Cross-platform Development
When doing cross-platform development, the common usage is preprocessor
#ifdef
macros to compile specific parts. This quickly makes the code
unreadable.
The usual abstraction : the preprocessor
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 for1.
As an examples, we have the following files2:
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
}
fileiosys.cpp
// 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);
fileiosys.h
In 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. Therefore the programmer has to demultiplex those in real time each time and every 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.
Compile-time polymorphism
The header file is the common interface
The interface is the most important part of the design. Being that interface is the original purpose of the header file.
Its design is hard :
- high-level enough to mask the differences between the plateforms you want to support
- low-level enough to avoid duplicating to much code.
So, for our example 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 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 unchanged : fileiosys.h
. Whereas the implementations are in
the files linux/fileiosys.cpp
and win32/fileiosys.cpp
.
// Define the forward declaration
struct fis_filedesc;
fis_filedesc* open(char* filename);
int close(struct fis_filedesc* fd);
fileiosys.h
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 (struct fis_filedesc* fd) {
int retval = CloseHandle(fd->handle) ? 0 : -1;
delete(fd);
return retval;
}
win32/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 (struct fis_filedesc* fd) {
int retval = close(fd);
delete(fd);
return retval;
}
linux/fileiosys.cpp
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.