(March 2011, Reddit-ed)
Over the years, I've seen way too many C programmers declare that they hate C++. They have their reasons, I'm sure. It is my humble opinion, however, that their hatred is... wrong. It took me quite some time (years) to slowly understand what is good and what is bad about C++, and I think the best way to consolidate my C++ experience is this:
The less code you write, the better. As you gain experience by writing more and more code for your projects, you will inevitably realize that brevity is a virtue: You fix a bug in one place, not many - you express an algorithm once, and re-use it in many places, etc. (Greeks even have a saying, traced back to the ancient Spartans: "to say something in less words, means that you are wise about it"). And the fact of the matter is, that when used correctly, C++ allows you to express yourself in far less code than C does.
Other languages offer this advantage, too - especially the ones from the functional programming world. But only C++ does this without costing you runtime speed. At all.
Finally, compared to C, C++ is a lot more safe - that is, it catches more errors at compile-time.
Let's look at a simplified version of a problem I faced with my renderer: While drawing triangles on the screen, and depending on the currently selected drawing mode, the algorithm must interpolate different values across a triangle's scanline: It must start from an X coordinate x1, and reach an X coordinate x2 (from the left to the right side of a triangle), and across each step, that is, across each pixel it passes over, it must interpolate various "stuff".
typedef struct tagPixelDataAmbient { float ambientLight; int x; } PixelDataAmbient; ... // inner loop currentPixel.ambientLight += dv;
typedef struct tagPixelDataGouraud { float red; float green; float blue; // The RGB color interpolated per pixel int x; } PixelDataGouraud; ... // inner loop currentPixel.red += dred; currentPixel.green += dgreen; currentPixel.blue += dblue;
typedef struct tagPixelDataPhong { float nX; float nY; float nZ; // The normal vector interpolated per pixel int x; } PixelDataPhong; ... // inner loop currentPixel.nX += dx; currentPixel.nY += dy; currentPixel.nZ += dz; // lighting equation uses interpolated normal value...
So, how would we continue in C?
Well, some C programmers would go "heck, lets write 3 functions that interpolate the values, and call them depending on the set mode".
But that makes us realize that we have a type problem - what is the type we work with? Are the pixels PixelDataAmbient? PixelDataGouraud? PixelDataPhong?
Oh, wait, the efficient C programmer says, use a union!
typedef union tagSuperPixel { PixelDataAmbient a; PixelDataGouraud g; PixelDataPhong p; } SuperPixel;
..and then, you have a "poly"-function...
RasterizeTriangleScanline( enum mode, // { ambient, gouraud, phong } SuperPixel left, SuperPixel right) { int i,j; if (mode == ambient) { // handle pixels as ambient... int steps = right.a.x - left.a.x; float dv = (right.a.ambientLight - left.a.ambientLight)/steps; float currentIntensity = left.a.ambientLight; for (i=left.a.x; i<right.a.x; i++) { WorkOnPixelAmbient(i, dv); currentIntensity+=dv; } } else if (mode == gouraud) { // handle pixels as gouraud... int steps = right.g.x - left.a.x; float dred = (right.g.red - left.g.red)/steps; float dgreen = (right.g.green - left.g.green)/steps; float dblue = (right.g.blue - left.g.blue)/steps; float currentRed = left.g.red; float currentGreen = left.g.green; float currentBlue = left.g.blue; for (j=left.g.x; i<right.g.x; j++) { WorkOnPixelGouraud(j, currentRed, currentBlue, currentGreen); currentRed+=dred; currentGreen+=dgreen; currentBlue+=dblue; } } else if (mode == ...
The code above must make the hairs on your neck stand up. Can you feel the chaos slipping in?
First of all, one typo is all that is needed to crash this code, since the compiler will never stop us in the "Gouraud" section of the function, from actually accessing the ".a." (ambient) values. A bug not caught by the type system (i.e. during compilation), means a bug that manifests at run-time, one that will require debugging. Did you notice that I am accessing left.a.x in the calculation of "steps"? The compiler surely didn't say anything.
Then, there is repetition everywhere - the for loop is there for as many times as there are rendering modes, we keep doing "right minus left divided by steps". Ugly, and error-prone. Did you notice I compare using "i" in the Gouraud loop, when I should have used "j"? The compiler is again, silent.
And about the if/else/ ladder for the modes... What if I add a new rendering mode, in 3 weeks? Will I remember to handle the new mode in all the "if mode==" in all my source files? (in case it's not clear, think: "in all 15 places in various .cpp files")
Now compare the above ugliness, with this set of C++ structs and a template function:
struct CommonPixelData { int x; }; struct AmbientPixelData : CommonPixelData { float ambientLight; }; struct GouraudPixelData : CommonPixelData { float red; float green; float blue; // The RGB color interpolated per pixel }; struct PhongPixelData : CommonPixelData { float nX; float nY; float nZ; // The normal vector interpolated per pixel }; template <class PixelData> void RasterizeTriangleScanline( PixelData left, PixelData right) { PixelData interpolated = left; PixelData step = right; step -= left; step /= int(right.x - left.x); // divide by pixel span for(int i=left.x; i<right.x; i++) { WorkOnPixel<PixelData>(interpolated); interpolated += step; } }
Be objective, now. Look at the code above, calmly, and notice some things.
We no longer make a union type-soup: we have specific types per each mode. They re-use their common stuff (the "x" field) by inheriting from a base class (CommonPixelData). And the template makes the compiler CREATE (that is, code-generate) the 3 different functions we would have written ourselves in C, but at the same time, being very strict about the usage of the types! We can't mess up like we did before - accessing non-existing fields will trigger a compile-time error.
Our loop in the template cannot goof and access invalid fields - the compiler will bark if we do.
The template performs the common work in one place (the loop, increasing by "step" in each time). The interpolation per type (AmbientPixelData, GouraudPixelData, PhongPixelData) is done with the operator+=() that we will add in the structs - which basically dictate how each type is interpolated. Clear separation of concerns - the loop is one thing, the "delta" logic for each type is another.
And do you see what we did with WorkOnPixel? We want to do different work per type - so we simply call a template specialization:
template <class T> void WorkOnPixel(T& p); template<> void WorkOnPixel(AmbientPixelData& p) { // use the p.ambientLight field } template<> void WorkOnPixel(GouraudPixelData& p) { // use the p.red/green/blue fields } ...
The function to call, is decided based on the type. At compile-time!
So, to summarize:
We minimize the code (via the template), by re-using common parts, we don't use ugly hacks, we keep a strict type system, so that the compiler can help us as much as possible, by detecting errors at compile-time.
And best of all: none of what we did has ANY run-time speed impact. This code will run JUST as fast as the equivalent C code - in fact, if the C code was "smart", and used function pointers to call the various WorkOnPixel versions, the C++ code will be FASTER than C, because the compiler will inline the type-specific WorkOnPixel template specialization calls!
Less code, no run-time overhead, more safety.
But this says nothing about C++. It only says... watch who you work with. There is no language to shield you from incompetent developers (no, not even Java).
I can only think of one reason to choose C over C++: that of a platform without a decent C++ compiler (in embedded development for micro-controllers, for example).
So keep studying and using C++ - just don't overdesign.
Index | CV | Updated: Tue Jun 13 21:40:53 2023 |