Notes on my solution of Lab 3

Disclaimer

The design of this solution is far from perfect. On the one hand, I tried to keep it general enough so that I could extend it to several considerably different projects (such as the "arkanoid" and the "alien evasion"). On the other hand, I tried to use only the language constructs we discussed to date in class. Please take the result for the compromise it is.

One case where this approach lead me into trouble was the so-called const correctness. Please read the corresponding note below. It contains information that I may never have a chance to present in class.

Sources

utils.h, shared utility definitions

wall.h, class Wall

ball.h,   ball.cpp, class Ball

main_lab3.cpp, the driver code

Recall that CoreTools define the struct Rect for rectangles:

  struct Rect {
    int left;
    int top;
    int right;
    int bottom;
  };
For reasons of backwards compatibility this struct has no member functions or constructors (except the default one). Instead, several functions are defined to handle instances of Rect, most notably Rect MakeRect(int,int,int,int) that creates and returns a new Rect, filled with the four integers for its left, top, right and bottom data members. Standard graphics routines like PaintRect(..) have a form that accepts a single Rect as an argument, so the following works:
  Rect r;
  r = MakeRect( 100, 100, 200, 200 );  // member-wise assignment
                                       // from the nameless Rect
                                       // returned by MakeRect into r 
  PaintRect( r );
I provided the function bool IntersectRects(..) that checked if two Rects, its arguments, intersected.

Files

The simplest solution is to put all classes in one file, together with the main() and the simulation driver code, and never care. This approach is not so good, though, if you are going to extend your code. Placing code in several files forces you to care about the mutual dependencies of classes and saves a lot of effort later.

All the code for Wall is in wall.h. Since the object doesn't do much, its member functions are all very simple and short. In that case creating a separate .cpp file doesn't make much sense.

The file ball.h contains the definition of the class Ball, ball.cpp contains the larger member functions. Notice that to scan the prototypes of member functions in class Ball, we only need to know that Wall they are referring to is a valid class name, nothing more. The so-called forward declaration class Wall; tells the compiler just that. On the other hand, in order to compile the bodies of these functions, we need to know details about the internal structure of Wall, which will become known only after the entire definition of class Wall has been scanned. Forward declarations and separating prototypes (in the class definition in a header file) from the actual code for member functions (in a .cpp file, after all definitions are included) is a necessity when you have several classes that cross-reference each other.

Comments on the design

The universe of this project consists of walls and balls. Walls are static and pretty much just sit there, occasionally changing the velocity components of balls. Thus walls is passive objects, whose status is not changed or updated, while balls are active objects whose status is updated on each iteration of the simulation "time" loop. (This changes in the arkanoid extension, where walls -- target blocks -- start moving down after being hit by a ball).

Both classes have member function Rect Region() to make and return the Rect that the object currently occupies. This function is convenient for collision detection. Besides, both classes have functions void Draw() and void Erase(), which should be the only way their instances get drawn and erased.

The classes cooperate, that is, the member functions of Ball call need to know information about instances of Wall. On the other hand, one wants to keep class members private when one can. This presents a design problem, which can be solved in three ways:

  1. Make Wall a struct, all members public. This is bad style.
  2. Declare Ball to be a friend of Wall, by putting the line friend class Ball; in the definition of Wall. This gives all member functions of Ball (but no one else) access to Wall's members. This is better.
  3. Add member functions to Wall that export information that is required, or render services that are needed. This is the current solution.
In more detail, Ball::Reflect( const Wall & w ) needs to know what rectangle its parameter, the wall w, occupies. If the first or the second alternative above were chosen, we'd just use w.rect (and the individual measurements w.rect.left, ..., w.rect.bottom). For the third alternative, this is not an option since w.rect is private. Instead, we add a member function Wall::Region() that simply reveals that private member to us:
Rect Wall::Region() const {
   return rect;
} 
Such simple functions are called accessor functions, or, more precisely, inspectors. Notice that now we can "see" rect, but cannot change it!

Further, for walls that accelerate or decelerate the ball, we need to know the acceleration/decelaration factor vfactor. Again, we could make w.vfactor accessible from Ball::Reflect( const Wall & w ), but this is not good design -- what if we later decide to change the way a speed change is implemented? This piece of information belongs in the description of Wall, and yet we'd have to change Ball::Reflect(..). This is definitely not good, because the very idea of using classes is that when we need to make changes to how a class is represented, other classes need not be touched, so that we don't end up with what is called spaghtetti code. Here is the solution: make the wall itself change the speed of the ball, at the ball's request, i.e. let the change happen in the member function Wall::ChangeSpeed(..). This way, a ball only calls this member function, and does not care how exactly it does its job:

vx = w.ChangeSpeed(vx);   or   vy = w.ChangeSpeed(vy);

For Task II, it is convenient to have another similar accessor function, Ball::Region() that makes and returns the Rect containing the ball. Thus we can check if a ball b[i] is placed on top of a wall w[j] by calling

IntersectRects( b[i].Region(), w[j].Region() ) 

Const correctness

In the solution of Lab 3 all const's can be removed, and the program will work just the same. Const's are a matter of style rather than a C++ necessity. Still, in larger projects, the use of const to declare the programmer's intentions is very beneficial.

Unfortunately, the rules associated with const are quite strict, and must be understood, or else the compiler is likely to hail you with a lot of non-informative error messages. Therefore we need to discuss these rules, the so-called const correctness.

There are several ways to pass an argument to a function:

If your function only needs to inspect the object, the const reference method is the right one, because no time is wasted making a copy of the object, and, at the same time, the object is protected from change. But, once you declare an object as const, you place restrictions on what you can do with it. In particular: Let us look at an example first. In this example we check if the next position of the ball overlaps with the rectangle occupied by the wall. The latter is obtained by calling Wall::Region() (see above):
bool Ball::Collides( const Wall & w ){
   // make the rect that contains the next position of the ball
   int nx = x + vx;   // next coords of the center
   int ny = y + vy; 
   Rect br = MakeRect( nx - r, ny - r, nx + r, ny + r );
   
   return IntersectRects( br, w.Region() );
} 
By declaring w as const & in the heading of the function, we make it a const object, not to be changed inside the function. But how does the compiler know what happens to w in the process of calling w.Region()? When Ball::Collides(..) starts compiling, nothing except the prototype of Wall::Region() may be known (takes no args; returns a Rect -- that's all). There is no way to tell at this point that Region() does not change w! For all we know, it can be wreaking havoc on this const object. The compiler is not designed to seek out the definition of Wall::Region() to verify that it makes no changes (that could require scanning other files etc.) Instead, you are supposed to make that explicit promise when you declare Wall::Region() (or any function that will be called on a const object). You do this by putting the keyword const after the argument list of the function:
Rect Wall::Region() const; 
This is different from Rect Wall::Region();, which makes no promise about not changing the state on the wall. Without the const keyword in the definition of
Rect Wall::Region() const {
  return rect;
} 
the compiler will give an error at w.Region() above, complaining about "calling a non-const function for a const object".

The general rule is this: once an object has been declared const (say, if it is an argument to a function, passed by const reference), only functions with "const" in the prototype can be called for that object.

Once again, observe that we are talking about prototypes, because the bodies of the functions may not have been scanned yet. For some class X,   void X::foo() and void X::foo() const are two different functions from the point of view of the compiler, although they seem to have the same name and same arguments. That const makes all the difference. Only the const version can be called for const objects.

Example:

class Counter {
  int num;
public:
  Counter() : num(0) {}
  int get_count() { return num; }
  void reset()    { num = 0; }
  void incr()     { num++; }
};

void foo( const Counter & c );

main(){
  Counter mycounter;
  
  foo( mycounter );
}  

void foo( const Counter & c ){
  if( c.get_count() > 0 )
     cout << "counter is non-zero\n";
  else 
     cout << "counter is zero\n";
}
This won't compile, the call to c.get_count() in foo() will generate an error. Indeed, although c is declared const, a non-const member function get_count() is called on it. Again, the actual code for Counter::get_count() is not analyzed (that would be too much work for the compiler), it's the prototype that matters. Writing
  int get_count() const { return num; } 
instead will remedy the problem. The member function get_count() will then be fit to be called on both const and usual Counters. Of course, when you write the code for its body, you have to make good on your promise that it doesn't change the data. For example, this will not compile:
  int get_count() const { num++; return num; } 
because a const function cannot change data members, which is the whole point of its "const" definition.