(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:

C++ allows you to write less code, with no run-time overhead, and with more safety, than C does.

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.


Does this mean that C++ is the be-all and end-all of languages? Of course not. You still have to measure your trade-offs and choose wisely from your arsenal of weapons (which implies that you need an arsenal, not just one hammer). Ignorant people will use C++ when they should have written a bash/Perl/Python script. And staying within the language, you need restraint: Trigger-happy C++ newbies will create deep nested classes with virtual multiple inheritance before you can stop them and send them packing. On the other end of the spectrum, nasty "gurus" will use complex Boost-like meta-programming for very simple things, thus creating "write-only" code (i.e. code that is impossible to read and maintain). Or, to re-visit our original premise, recent immigrants from the world of C will STILL use char*,strcmp and macros, instead of std::string and templates.

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.


profile for ttsiodras at Stack Overflow, Q&A for professional and enthusiast programmers
GitHub member ttsiodras
 
Index
 
 
CV
 
 
Updated: Tue Jun 13 21:40:53 2023
 

The comments on this website require the use of JavaScript. Perhaps your browser isn't JavaScript capable; or the script is not being run for another reason. If you're interested in reading the comments or leaving a comment behind please try again with a different browser or from a different connection.