As an example, take a function get_page(const uri&) that queries a web server using HTTP and returns the content of the page as a string. Whether get_page() uses raw sockets, libcurl or Boost.Asio should not affect the calling code, since the facade is the same: give me a URI and I'll give you back a string.
There are many ways of isolating layers so that implementation details are hidden. Some allow only a relink, while others force a recompile, although without requiring modifications to the calling code. Some are easier on maintenance, but are more complex to implement.
As with most things in programming, there is always a compromise to be made between development time and maintenance time. Experience will make the extent of this compromise easier to define, based on the nature of the program, time constraints, skill and managerial issues. While ignoring encapsulation may be just fine for small, quick-and-dirty programs, it is not for larger codebases. Remember also that what started as a small program can easily get out of hands if its scope gets larger with time. I've been bitten more than once by the "bah, 'tis just a small debug thing, oh darn now it needs a boatload of features, oh crap it needs to be ported to Linux, oh god help me now it's got to run in production."
Never assume that your "small debug thing" will stay that way. Always program as well as your time constraints allow.
The following examples are taken from a real project that pretty much went through these "oh crap" steps. Although it needed some refactoring several times, using some kind of encapsulation from the start helped me reduce the time needed for modifications.
The final version of this program takes a list of (x, y) points from an external source (such as a CSV file) and displays them on an OpenGL context. It needs to support basic graphical operations such as zooming and panning, as well as data modifications such as rotating and translating the points. It has to run on both Windows and QNX.
The examples will concentrate on the OpenGL part, because it can demonstrate the different methods of encapsulation:
- Windows supports OpenGL 1.1 out of the box, while QNX only supports OpenGL ES 1.0, a subset of OpenGL.
- The creation of the OpenGL context and user interface elements is different on both platforms.
- OpenGL ES is messy to use and benefits from some sort of wrapping.
The following is used throughout this article:
#include <vector> // either of those is defined to 1 on the right platform #define QNX 0 #define WIN 0 struct point { int x, y; point() : x(0), y(0) { } }; typedef std::vector<point> points; points get_points() { points ps; // loads the point from an external source return ps; }
#include <boost/foreach.hpp> #define foreach BOOST_FOREACH
No encapsulation
The simplest in term of initial development time is to ignore encapsulation (or any other method of making the code "better".) Doing so will quickly give you a working program that will probably do exactly what you want. Let's make one.
The development started on Windows with OpenGL 1.1. It needs to create a window and an OpenGL context to draw on.
// opengl_context.h class opengl_context { public: opengl_context(HWND h); ~opengl_context(); void begin(glEnum type); void add(const points& ps); void end(); private: HDC gl_; };
// opengl_context.cpp #include "opengl_context.h" opengl_context::opengl_context(HWND h) : gl_(0) { gl_ = // create the context using 'h' } opengl_context::~opengl_context() { // destroy the context ReleaseDC(gl_); } void opengl_context::begin(glEnum type) { // this tells OpenGL that the next calls will affect // the given context wglMakeCurrent(gl_, 0); // clear the screen glClearColor(0.0f, 0.0f, 0.0f, 0.0f); glClear(GL_COLOR_BUFFER_BIT); glBegin(type); } void opengl_context::add(const points& ps) { foreach(const point& p, ps) glVertex2i(p.x, p.y); } void opengl_context::end() { glEnd(); }
// main.cpp #include "opengl_context.h" HWND create_window(); int main() { HWND h = create_window(); opengl_context gl(h); points ps = get_points(); gl.begin(GL_POINTS); gl.add(ps); gl.end(); DestroyWindow(h); }
The constructor somehow creates a context that is destroyed in the destructor.
Although this might seem simple enough, it's also a mess from the point of view of encapsulation. It exposes several things:
- It exposes the Windows-specific type HDC to users of the class.
- The interface assumes that begin(), add() and end() corresponds to an OpenGL idiom that's available on the platform.
- It mixes the implementation-specific wglMakeCurrent() (part of the Windows OpenGL implementation), the OpenGL 1.1 calls (glBegin(), glEnd() and glVertex2i()) and functions available under both Windows and QNX (glClearColor() and glClear()).
First, a window handle is not a HWND anymore, it's a PtWidget_t*:
#include "opengl_context.h" PtWidget_t* create_window(); int main() { PtWidget_t* h = create_window(); opengl_context gl(h); points ps = get_points(); gl.begin(GL_POINTS); gl.add(ps); gl.end(); PtDestroyWidget(h); }
However, you'll run into trouble with the implementation of gl.begin() and gl.end() because the corresponding OpenGL function glBegin() and glEnd() don't exist in QNX. This is because OpenGL ES uses a completely different way of setting up the points to display, which is not easily represented by our begin(), add() and end() idiom.
The class definition of opengl_context also doesn't compile because it mentions HWND and HDC. Its implementation is also broken because of two reasons:
- it uses Windows-specific calls (such as ReleaseDC()), and
- it uses OpenGL 1.1 calls, while QNX only supports OpenGL ES 1.0
Types of encapsulation
There is more than one way of modifying opengl_context to be portable. Obviously, the simple scheme used so far is very hard to port: there are platform-specific types in the class definition, platform-specific calls in the member functions and assumptions from the interface on how to interact with OpenGL.
There are several ways to hide the implementation details:
- use conditional compilation so that only the right function calls and types are compiled in.
- move all the platform specific calls to a different class and keep a pointer to it, using inheritance to choose the right implementation.
- move all the platform specific calls to somewhere outside the class, use conditional compilation to choose the right implementation and let the linker do its work.
- same as the previous point, but use policy and traits classes to choose the right implementation; this would require opengl_context to be templated.
Let's start with the simplest.
Conditional compilation
As with everything, conditional compilation is simple and quick, but leads to hate and suffering for a variety of reasons.
Let's modify the class definition so it is portable under both Windows and QNX:
class opengl_context { public: #if WIN opengl_context(HWND h); #elif QNX opengl_context(PtWidget_t* h); #endif ~opengl_context(); void begin(glEnum type); void add(const points& ps); void end(); private: #if WIN HDC gl_; #elif QNX EGLContext gl_; #endif };
// opengl_context.cpp #include "opengl_context.h" #if WIN opengl_context::opengl_context(HWND h) #elif QNX opengl_context::opengl_context(PtWidget_t* h) #endif : gl_(0) { gl_ = // create the context using 'h' } opengl_context::~opengl_context() { // destroy the context #if WIN ReleaseDC(gl_); #elif QNX eglDestroyContext(.., gl_); #endif } void opengl_context::begin(glEnum type) { // this tells OpenGL that the next calls will affect // the given context #if WIN wglMakeCurrent(gl_, 0); #elif QNX eglMakeCurrent(.., gl_); #endif // clear the screen glClearColor(0.0f, 0.0f, 0.0f, 0.0f); glClear(GL_COLOR_BUFFER_BIT); glBegin(type); } void opengl_context::add(const points& ps) { foreach(const point& p, ps) glVertex2i(p.x, p.y); } void opengl_context::end() { glEnd(); }
- What if we need to support a third platform? Making sure every case is covered is a nightmare, unless you add an #else with some kind of #error. But this needs discipline: what if you forget to change one instance which happens to compile but not do what is expected?
- This makes it harder to see what's happening because although the different cases may be equivalent (both are constructors taking a handle, for example), they are duplicated. It breaks the normal flow of operations in such a way that understanding the code is much harder.
- It creates many variations of the same program. This is the case with any sort of conditional compilation: depending on a macro, the compiler will see different things. This is a nightmare to maintain and test correctly.
- I see #ifdef being used a lot more than #if in these cases. This adds another danger, since #ifdef obviously cannot distinguish between a typo and a name that was deliberately not defined.
- Programs evolve. Try to be prepared for as many changes as possible, especially in areas like portability. If there's one line that needs to be ifdef'ed, there's probably going to be more eventually.
- Discipline is hard. If parts of your code requires discipline to maintain (such as adding a new #elif), it is brittle.
- Readability is paramount. Code needs to be simple and easy to read, not only for others' sake, but for yours. It is only a matter of days, not weeks nor months, before you starting looking at your code with fresh eyes.
Implementation class with inheritance
A common way of hiding the implementation of a class is to move all the platform-specific code to a separate class and keep an opaque pointer to it. This is often called the "pimpl" idiom, from the name pImpl that is often given to the pointer. An opaque pointer is simply a pointer to an incomplete type, that is, a type that has only been declared, not defined.
The advantage is that everything that is platform-specific does not leak into the calling code. It also makes it possible to completely separate the implementations from each other, which makes the code clearer, while leaving the common code together.
Designing a good implementation class is not easy: it needs to be flexible enough so it can be used in different ways. A member function like impl::update() might look alright, until you realize it is too general because you need to execute only a part of it. You then need to break it down in smaller pieces. Note that this is not a bad thing in itself, since it prevents you from doing some copy/pasting that you otherwise might be tempted to do.
The "pimpl" idiom is usually implemented with a base class, let's call it opengl_context_impl, from which implementation-specific classes derive, let's call them opengl_context_win and opengl_context_qnx. One of those is created on the heap and a pointer to the base class is kept.
There are two main problems with this:
- It uses the heap and virtual functions. Not all applications can use the heap freely (such as in embedded computers) and forwarding to a virtual function through a pointer to a base class is usually slower than calling a member function directly.
- It does not solve the problem of passing implementation-defined values through the facade to the implementation, such as the HWND or PtWidget_t* in the constructor.
- Pass a void* or some type such as an unsigned int that can be forwarded to the implementation and converted to the right type. This is dangerous because the size of a void* or unsigned int might not be the same as a HWND or a PtWidget_t*. You can also end up with invalid bit patterns because of the reinterpret_cast involved, which is undefined behavior. It also looses type information, forcing the implementation to assume that the value given is alright.
- Have the calling code create the implementation class and give it to the facade in its constructor. This is probably the simplest way. The implementation still leaks to the calling code, but it is hopefully in a place where there is already platform-specific stuff anyways (such as in an entry point where the inherently platform-specific user interface is created.) However, unless the calling code remembers that pointer, it looses access to the implementation. It might be necessary, for example, to retrieve the window handle (HWND or PtWidget_t*) so it can be passed to another function.
- Either make the facade templated on the types needed or use traits classes. This is a more complex approach that is detailed below.
class opengl_context_impl { public: virtual ~opengl_context_impl(); virtual void make_current() = 0; };
#if WIN class opengl_context_win : public opengl_context_impl { public: opengl_context_win(HWND h) : gl_(0) { gl_ = // create the context using 'h' } virtual ~opengl_context_win() { // destroy the context ReleaseDC(gl_); } virtual void make_current() { wglMakeCurrent(gl_, 0); } private HDC gl_; }; #endif
#if QNX class opengl_context_qnx : public opengl_context_impl { public: opengl_context_qnx(PtWidget_t* h) : gl_(0) { gl_ = // create the context using 'h' } virtual ~opengl_context_qnx() { // destroy the context eglDestroyContext(.., gl_); } virtual void make_current() { eglMakeCurrent(.., gl_); } private EGLContext gl_; }; #endif
Modifying opengl_context to use the implementation is easy:
// opengl_context.h class opengl_context_impl; class opengl_context { public: opengl_context(std::auto_ptr<opengl_context_impl> impl); void begin(glEnum type); void add(const points& ps); void end(); private: std::auto_ptr<opengl_context_impl> impl_; };
// opengl_context.cpp #include "opengl_context.h" opengl_context::opengl_context(std::auto_ptr<opengl_context_impl> impl) : impl_(impl) { } void opengl_context::begin(glEnum type) { // this tells OpenGL that the next calls will affect // the given context impl_->make_current(); // clear the screen glClearColor(0.0f, 0.0f, 0.0f, 0.0f); glClear(GL_COLOR_BUFFER_BIT); glBegin(type); } void opengl_context::add(const points& ps) { foreach(const point& p, ps) glVertex2i(p.x, p.y); } void opengl_context::end() { glEnd(); }
#include "opengl_context.h" #if WIN typedef HWND handle_type; typedef opengl_context_win impl_type; #elif QNX typedef PtWidget_t* handle_type; typedef opengl_context_qnx impl_type; #endif handle_type create_window(); int main() { handle_type h = create_window(); opengl_context gl( std::auto_ptr<opengl_context_impl>(new impl_type(h))); points ps = get_points(); gl.begin(GL_POINTS); gl.add(ps); gl.end(); #if WIN DestroyWindow(h); #elif QNX PtDestroyWidget(h); #endif }
Now, this problem is not specific to my example, nor to OpenGL or QNX. This is only a way of showing a common problem: because portability was not part of the original specifications, no research has been done on the differences between the platforms. In this example, I would have found that Windows supports OpenGL 1.1 while QNX only supports OpenGL ES 1.0. Other examples could include different socket implementations, user interfaces, filesystems, hardware access or threading facilities.
What's important here is that the differences between platforms might affect the API. Design your API to match how a specific implementation works too closely, that is, create an API that is not abstracted enough, and you'll run into trouble when you try to port it.
So let's modify opengl_context and see what happens. In this case, the API needs to be broken down in smaller pieces. Other cases would see the API get more complex or even radically different.
// opengl_context.h class opengl_context_impl; class opengl_context { public: opengl_context(std::auto_ptr<opengl_context_impl> impl); void make_current(); void clear(); void add(const points& ps); private: std::auto_ptr<opengl_context_impl> impl_; };
// opengl_context.cpp #include "opengl_context.h" opengl_context::opengl_context(std::auto_ptr<opengl_context_impl> impl) : impl_(impl) { } void opengl_context::make_current() { // this tells OpenGL that the next calls will affect // the given context impl_->make_current(); } void opengl_context::clear() { glClearColor(0.0f, 0.0f, 0.0f, 0.0f); glClear(GL_COLOR_BUFFER_BIT); } void opengl_context::add(const points& ps) { glEnableClientState(GL_VERTEX_ARRAY); glVertexPointer(2, GL_INT, 0, &ps[0]); glDrawArrays(GL_POINTS, 0, static_cast(ps_.size())); glDisableClientState(GL_VERTEX_ARRAY); }
int main() { handle_type h = create_window(); opengl_context gl( std::auto_ptr<opengl_context_impl>(new impl_type(h))); points ps = get_points(); gl.make_current(); gl.clear(); gl.add(ps); #if WIN DestroyWindow(h); #elif QNX PtDestroyWidget(h); #endif }
A problem with this scheme is that accessing the implementation is now impossible, unless a opengl_context::get_impl() is added and its return value cast to the appropriate derived class. The reasons for accessing the implementation are varied, but one that comes to mind is a Windows-only function that could set the keyboard focus on the window that contains a specific context:
void set_focus(opengl_context& c) { ::SetFocus(c.??); }
- You could try adding a get_impl() member function that returns a opengl_context_impl, but even adding a virtual get_handle() wouldn't help because you can't return the right type.
- You could try returning a void* or another generic type and cast it to the right handle type, but that has many problems as mentioned before (such as mismatched sizes, bit patterns, etc.)
- You also could cast what get_impl() returns to the right implementation type (such as opengl_context_win) and add a non-virtual get_handle() that returns the right handle type (such as HWND) to it.
- Finally, you could make set_focus() a virtual function in the base class that could be implemented correctly by the derived classes, but this opens a can of worms: how many of these functions do you need to add to the base class? If a platform has no notion of keyboard focus, does it make sense to add set_focus() to the base class? What about a move_window() or a capture_mouse()?
It also allows changing the implementation at runtime in certain cases. For example, a database class could be able to switch at runtime from a database_mysql to a database_sqlite depending on the file that is currently loaded.
Implementation class with static types
In cases such as a platform-specific implementations that cannot be switched at runtime (like our opengl_context class), static types can be more efficient and flexible than inheritance-based types used through a pointer to the base class.
Basically, you remove the base class opengl_context_impl and have opengl_context either inherit from the right implementation class or keep it as a member object (not a pointer). By naming the two implementations classes identically, you let the compiler decide which implementation class to include based on macros and the linker will put this together.
Let's get back to the implementation:
#if WIN class opengl_context_impl { public: typedef HWND handle_type; opengl_context_impl(HWND h) : gl_(0) { gl_ = // create the context using 'h' } ~opengl_context_impl() { // destroy the context ReleaseDC(gl_); } void make_current() { wglMakeCurrent(gl_, 0); } private: HDC gl_; }; #endif
#if QNX class opengl_context_impl { public: typedef PtWidget_t* handle_type; opengl_context_impl(PtWidget_t* h) : gl_(0) { gl_ = // create the context using 'h' } ~opengl_context_impl() { // destroy the context eglDestroyContext(.., gl_); } void make_current() { eglMakeCurrent(.., gl_); } private: EGLContext gl_; }; #endif
class opengl_context { public: opengl_context(opengl_context_impl::handle_type h); void make_current(); void clear(); void add(const points& ps); private: opengl_context_impl impl_; };
opengl_context::opengl_context(opengl_context_impl::handle_type h) : impl_(h) { } void opengl_context::make_current() { // this tells OpenGL that the next calls will affect // the given context impl_.make_current(); } // clear() and add() are identical
int main() { opengl_context_impl::handle_type h = create_window(); opengl_context gl(h); points ps = get_points(); gl.make_current(); gl.clear(); gl.add(ps); #if WIN DestroyWindow(h); #elif QNX PtDestroyWidget(h); #endif }
Both ways are arguable, but I prefer reducing the visibility as much as I can. If I found out that pretty much all the member functions from opengl_context_impl need to be accessed from the outside, I'd publicly inherit from it. If not, I'd aggregate an object, add the functions needed to opengl_context and forward them. This could be offset by making the member functions protected, but this would force users to always inherit from it.
The main problem is that the implementation now needs to be constructed from opengl_context. Currently, it uses the handle_type typedef and therefore does not need any conditional compilation. However, this assumes that the constructor of both implementation classes can be called identically (like it is the case right now, with only a window handle). If not, then the constructor would either need to be ifdef'ed or, better, an initialization structure could be defined by the implementation and filled from the calling code:
#if WIN struct opengl_context_init { HWND h; }; class opengl_context_impl { public: opengl_context_impl(const opengl_context_init& init); // as before }; #endif
#if QNX struct opengl_context_init { PtWidget_t* h; gf_dev_t gfdev; PdOffscreenContext_t* offscreen; }; class opengl_context_impl { public: opengl_context_impl(const opengl_context_init& init); // as before }; #endif
class opengl_context { public: opengl_context(const opengl_context_init& init); // as before };
opengl_context::opengl_context(const opengl_context_init& init) : impl_(init) { } void opengl_context::make_current() { // as before // ...
int main() { opengl_context_init init; #if WIN init.h = create_window(); #elif QNX init.h = create_window(); init.gfdev = gf_dev_attach(..); init.offscreen = PdCreateOffscreenContext(..); #endif opengl_context gl(init); // as before }
class opengl_context { public: opengl_context(const opengl_context_init& init); opengl_context_impl::handle_type handle(); // as before };
void set_focus(opengl_context& c) { ::SetFocus(c.handle()); }
- The implementations are still cleanly separated from the common code.
- The platform-specific information needed for the initialization is taken out of opengl_context and moved to the calling code, which might already be platform-specific anyways (such as in main()).
- There is no efficiency penalty: member functions are called through an object using its static type. No virtual functions or pointers are involved.
- Type information is retained because no inheritance is used.
Implementation class with templates
The previous examples with static typing work well in most cases. However, this assumes that all implementations will support the same operations and that only one implementation can be selected at compile-time.
Let's add a member function to opengl_context that can return some information, such as the current model view matrix. The details are unimportant: this is just an array of 16 floats that is returned from a call to glGetFloatv().
However, this function is unavailable on QNX because it is not part of OpenGL ES 1.0. In the particular program I am taking these examples from, the user interfaces in QNX and Windows are substantially different, such that the QNX port never needs to call it.
Therefore:
std::vector<float> opengl_context::model_view() { float matrix[16]; glGetFloatv(GL_MODELVIEW_MATRIX, matrix); return std::vector<float>(matrix, matrix+16); }
You could implement model_view() in the Windows version of opengl_context_impl, but not in the QNX version and inherit from the implementation class instead of aggregating an object. This way, calling model_view() from Windows would work, while calling it from QNX would give a compilation error. This, however, requires inheritance. Although this is not necessarily a bad thing, it can be implemented in a way that both inheritance and aggregation are supported.
By making opengl_context templated on the implementation type, you can add member functions which wouldn't necessarily compile if called, but won't cause problems if left alone.
Let's start with the implementation classes:
#if WIN class opengl_context_win { public: typedef HWND handle_type; struct init { handle_type h; }; opengl_context_win(const init& i) : gl_(0) { gl_ = // create the context using 'i' } ~opengl_context_win() { // destroy the context ReleaseDC(gl_); } void make_current() { wglMakeCurrent(gl_, 0); } std::vector<float> model_view() { float matrix[16]; glGetFloatv(GL_MODELVIEW_MATRIX, matrix); return std::vector<float>(matrix, matrix+16); } private: HDC gl_; }; #endif
#if QNX class opengl_context_qnx { public: typedef PtWidget_t* handle_type; struct init { handle_type h; gf_dev_t gfdev; PdOffscreenContext_t* offscreen; }; opengl_context_qnx(const init& i) : gl_(0) { gl_ = // create the context using 'i' } ~opengl_context_qnx() { // destroy the context eglDestroyContext(.., gl_); } void make_current() { eglMakeCurrent(.., gl_); } private: EGLContext gl_; }; #endif
template <class Impl> class basic_opengl_context { public: basic_opengl_context(const typename Impl::init& init) : impl_(init) { } void make_current() { impl_.make_current(); } void clear() { glClearColor(0.0f, 0.0f, 0.0f, 0.0f); glClear(GL_COLOR_BUFFER_BIT); } void add(const points& ps) { glEnableClientState(GL_VERTEX_ARRAY); glVertexPointer(2, GL_INT, 0, &ps[0]); glDrawArrays(GL_POINTS, 0, static_cast(ps_.size())); glDisableClientState(GL_VERTEX_ARRAY); } std::vector<float> model_view() { return impl_.model_view(); } private: Impl impl_; };
#if WIN typedef basic_opengl_context<opengl_context_win> opengl_context; #elif QNX typedef basic_opengl_context<opengl_context_qnx> opengl_context; #endif void f(opengl_context& c) { c.model_view(); }
One of the advantages of using a template is that it is easy to plug in different implementations at compile-time. Relying on conditionally compiled classes with the same name works when only one implementation may be selected (which is the case for our opengl_context). For example, a basic_database<> could be used with either a database_sqlite or a database_mysql.
The main downside to making opengl_context a template is that on most compilers, the member function definitions must be visible along with the class definition (unless you're in luck and have access to a compiler that supports export).
Wrapping up
Using conditional compilation is quick and easy, but is a nightmare in the long run. It spreads implementation-specific code throughout the program, with no easy way of having a general view of what runs in which case. It is the equivalent of magic numbers. It makes it harder to know exactly what the compiler is seeing with a particular set of macros. It also clutters the code with different versions of the same code, making it harder to follow the execution path.
Hiding the implementation behind a pointer works great when the particular implementation may change at runtime, but it has associated costs because it needs to call functions through a pointer and a virtual table. It also looses any type information present in the implementation class (such as the type of a window handle), forcing the user to get a pointer to the implementation and cast it to the appropriate type.
Using conditional compilation to select between two different implementations only works if the specific implementation cannot change at runtime. The advantage in this case is that type information is retained and function calls go through an object using its static type.
Both the "pimpl" and static type idioms have the advantage of consolidating all the platform specific code in the same place, making it easy to see what code is fed to the compiler. It is also easier to maintain. By looking at the calling code, it is possible to quickly understand what part is generic and what part is implementation-specific.
Making a generic class templated on the implementation class allows for adding member functions that would not necessarily compile otherwise. If not called on an implementation where these are unavailable, no error will be generated. It also makes it possible to easily switch between implementations at compile-time, without relying on macros (unless an implementation cannot compile on a particular platform or setup.) However, the cost is that on most compilers, the member function definitions have to be visible alongside the class definition.
Finally, the API of a particular class needs to be abstract enough so that it can be used naturally on different platforms where the backend might be substantially different.
As usual, YMMV.
[edit 29-sept-2011: layout]
No comments:
Post a Comment