Case Study: The Drawing Program Revisited

In Chapter 5, “Structures and Pointers,” the case study was a program to do drawings of common shapes. That program used a struct to keep information about each graphics shape. Unfortunately, this does not work very well if the shape isn't a simple rectangle or ellipse that can be described by two points, the lower left and upper right corners. You would have to put in a pointer, which might contain extra point data. So the first problem is how to force complicated shapes into a simple struct.

The second problem has to do with program organization. The previous program relied on case statements, and this gets awkward when shapes have other properties as well. For example, users would like to select graphics shapes interactively. This would invariably lead to the need for a new case statement. Adding new types of shapes means that code in several places must be changed.

This case study shows how a class hierarchy of graphics shapes can produce a straightforward design that is easy to extend.

The first important part of designing any application is to think about the classes required. In this case, you need a window object and a number of shape objects. All shapes have certain properties in common; they are required to draw themselves, and they must be able to read and write themselves to disk. The class Shape is abstract and defines an interface that all real Shape objects must supply. That is, any real graphics shape will be derived from Shape and override methods like draw() and read() (you will find all this code in chap8shapes.h, chap8shapes.cp, and chap8window.cpp):

class Shape {   // an abstract base class for all Shapes...
public:
  enum {  MANY = 999 };
  Shape() { }
  virtual int type() = 0;
  virtual void create(FPoint pts[], int n) = 0;
  virtual void draw(TG& tg) = 0;
  virtual int distance(const FPoint& p) = 0;
  virtual int npoints() = 0;
  virtual void read(std::istream& is) = 0;
  virtual void write(std::ostream& os) = 0;
};

Abstract base classes such as Shape are useful because they concentrate attention on the interface rather than on the implementation. The npoints() method is intended to return the number of points necessary; if it returns MANY, the object has an indefinite number of points (like a polyline). The distance() method is used so that an application can detect the closest object to a point. Any Shape object should supply a unique integer type() function. (Just for a change, this example does not make the std namespace global, so all std entries like std::string must be fully qualified names.)

As it stands, Shape is pure promise and no delivery. Because shapes like rectangles and ellipses can be described by a rectanglar bounding box, it makes sense to specialize Shape for doing such shapes, as in the following code:


class ShapeRect: public Shape {
protected:
  FPoint m_bottom_left, m_top_right;
public:

  void create(FPoint pts[], int n)
  {
     if (n != 2) throw Error("Supply two points for objects");
     m_bottom_left = pts[0];
     m_top_right = pts[1];
  }
  int distance(const FPoint& p)
  {
    return p.x > m_bottom_left.x && p.y > m_bottom_left.y
          && p.x < m_top_right.x && p.y < m_top_right.y;
  }
  int npoints()
  {  return 2; }

  void read(std::istream& is)
  {
    is >> m_bottom_left.x >> m_bottom_left.y
       >> m_top_right.x, m_top_right.y;
  }

  void write(std::ostream& os)
  {
    os << m_bottom_left.x << ' ' << m_bottom_left.y
       << m_top_right.x   << ' ' << m_top_right.y << std::endl;
  }

 };

In this example, the object's bounding rectangle is represented by two points, and the object knows how to read and write bounding rectangles. The distance() method is fairly crude; it is set to 1 if the object is inside and 0 if the object is outside the rectangle. The create() method simply creates the bounding rectangle from two points and throws an exception if given any other number of points. But ShapeRect does not override draw().

Since ShapeRect still does not specify how the object displays itself, it does not completely fulfill the contract. It is still an abstract class. The actual classes derived from ShapeRect, Rectangle, and Ellipse, (as shown in the following code) share everything in common except how to draw themselves. This is an excellent example of how using a common base class can simplify classes.

Note that these actual shapes have static New() member functions, which are used for dynamic creation of these objects. That is, Rectangle::New() creates a pointer to a Rectangle object. This may seem unnecessary because new Rectangle() does the job more simply, but you will see how useful this is when used with the type() method. Also, these classes have explicit default constructors that apparently do nothing. This is an UnderC limitation; within methods you can refer only to other members if they have been previously defined. new Rectangle() implicitly calls Rectangle() so I have to make sure that the default is defined first.

class Rectangle: public ShapeRect {
 public:
   Rectangle() { }
   static Shape *New() {  return new Rectangle(); }
   int type()          {  return 1; }

   void draw(TG& tg)
   {  tg.rectangle(m_bottom_left.x,m_bottom_left.y,
                  m_top_right.x, m_top_right.y);
   }
 };

 class Ellipse: public ShapeRect {
 public:
   Ellipse() { }
   static Shape *New() {  return new Ellipse(); }
   int type()          {  return 2; }

   void draw(TG& tg)
   {  tg.ellipse(m_bottom_left.x,m_bottom_left.y,
                  m_top_right.x, m_top_right.y);
   }
 };

There is another kind of Shape object you can implement that does not fit into the rectangle mold. A polyline consists of an arbitrary number of points—rather than just two—joined by lines. Because the representation of polylines is so different from the representation of regular lines, you must define special read() and write() methods for them, as in the following example:


typedef std::list<Fpoint> PointList;
..
class PolyLine: public Shape {
   PointList m_points;
 public:
   PolyLine() { }
   static Shape *New() {  return new PolyLine(); }
   int type()          {  return 3; }

   void create(FPoint pts[], int sz)
   {
    if (sz < 2) throw Error("at least one point");
    for(int i = 0; i < sz; i++)
      m_points.push_back(pts[i]);
   }
   int npoints() {  return MANY; }   // indefinite no. of points!

   void draw(TG& tg)
   {
      PointList::iterator pli;
      tg.penup();
      for(pli = m_points.begin(); pli != m_points.end(); ++pli)
         tg.plot(pli->x,pli->y);
    }

   int distance(const FPoint& p)
   {  return MANY; }   // for now

   void read(std::istream& is)
   {
    int n;
    FPoint p;
    is >> n;
    for(int i = 0; i < n; i++) {
       is >> p.x >> p.y;
       m_points.push_back(p);
    }
   }

   void write(std::ostream& os)
   {
     PointList::iterator pli;
     os << m_points.size() << std::endl;
     for(pli = m_points.begin(); pli != m_points.end(); ++pli)
        os << pli->x << ' ' << pli->y << std::endl;
   }

};

This class has a completely different representation than the other Shape classes, but it fulfills the same contract.

A collection of Shape objects needs a responsible adult to look after them and make sure they are fed and clothed. The window class ShapeWindow could do this in simple cases; you could keep and maintain a simple list of Shape pointers. However, it is better if the ShapeWindow concentrates on doing window-like things, such as managing the user interface, and delegates the management of Shape objects to ShapeContainer.

The ShapeContainer class need not derive from any class, and it would mostly contain the actual list of shapes. However, the example shows ShapeContainer derived from Shape for a number of reasons. It can draw itself (by drawing all the shapes); it can read and write itself (by asking all the shapes to read and write themselves); and distance() can be interpreted as the minimum distance to a Shape object. Plus, it is very common for complex drawings to be built of composite shapes; a child's drawing of a house is a triangle on top of a rectangle, plus a few rectangles for doors and windows. So real drawings could be composed of a number of ShapeContainer shapes. This case study won't add this functionality, but it would not be difficult to implement.

The following is the class definition for ShapeContainer; apart from the usual Shape methods, you can add shapes to the container and register new kinds of shapes by using add_shape():

// shape-container.h
#include "shapes.h"
typedef std::list<Shape *> ShapeList;
typedef Shape * (*SHAPE_GENERATOR)();

class ShapeContainer: public Shape {
private:
  ShapeList m_list;
  ShapeList::iterator p, m_begin, m_end;
  SHAPE_GENERATOR m_shape_generator[40];
public:

 void add_shape(int id, SHAPE_GENERATOR shg);
 void add(Shape *obj);

 // Shape Interface!
 void draw(TG& tg);
 void read(std::istream& is);
 void write(std::istream& os);
 int distance(const FPoint& p);

// no useful purpose so far, but required of a Shape.
void create(FPoint pts[], int n) {  }
int npoints() {  return MANY; }
int type() {  return 0; }
};

Adding and drawing shapes is straightforward; in this example, the iteration over all Shape objects is simplified by keeping the start and finish iterators for the list of shapes. Provided that everyone uses the add() method and doesn't manipulate m_list directly, this works fine. Both draw() and write() call their corresponding method for all objects; in addition, write() writes out the type value for each object. Here are the definitions of ShapeContainer's methods:

void ShapeContainer::add(Shape *obj)
{
   m_list.push_back(obj);
   m_begin = m_list.begin();
   m_end = m_list.end();
}

void ShapeContainer::draw(TG& tg)
{
    for (p = m_begin; p != m_end; ++p)
        (*p)->draw(tg);
}
void ShapeContainer::write(std::istream& os)
 {
    os << m_list.size() << endl;
    for (p = m_begin; p != m_end; ++p) {
        os << (*p)->type() << ' ';
        (*p)->write(os);
    }
 }

Before we can talk about the read() method, we should talk about why you need to write out the type value and why you then need those static New() functions. (Remember that a static member function is just a plain function that is defined inside a class scope, just like inside a namespace.) Here is code that shows a file consisting of a Rectangle and an Ellipse being read:

;> ifstream in("first.shp");
;> int i;
;> Shape *p;
;> in >> i;    // should be 1, which is type of Rectangle
;> p
						=
						new Rectangle();   // or you can say Rectangle::New()
;> p->read(in);
;> in >> i;   // will be 2, which is type of Ellipse
;> p
						=
						new Ellipse();
;> p->read(in);
					

This technique works beautifully, because the appropriate code for reading in the object will be called; read() is a virtual method. But you do need to construct the particular object first. How is this done?

The read() method of ShapeContainer first reads the type ID (as shown in the following code), and then it looks up the function needed to create the actual Shape object. ShapeContainer keeps a simple array of function pointers; these functions take no argument, and they return a pointer to a Shape object. These functions are sometimes called object factories.


typedef Shape * (*SHAPE_GENERATOR)();

void ShapeContainer::add_shape(int id, SHAPE_GENERATOR shg)
{
 m_shape_generator[id] = shg;
}
...
void setup_shapes(ShapeContainer& sc)
{
  sc.add_shape(1,Rectangle::New);
  sc.add_shape(2,Ellipse::New);
  sc.add_shape(3,PolyLine::New);
}

...
void ShapeContainer::read(std::istream& is)
{
   int n,id;
   Shape *obj;
   is >> n;
   for(int i = 0; i < n; i++) {
     is >> id;
     obj = (m_shape_generator[id]) (); // call object factory
     obj->read(is);
     add(obj);
   }
 }

The problem here is that you need to create the specific Shape object. After you do that, you can call the specific read() method to fully construct the object. If you call the correct object generator function using the unique ID, then read() will call the specific read() method.

The setup_shapes() function adds the various shape generators to the ShapeContainer object. Notice that setup_shapes() is not a member of ShapeContainer because ShapeContainer need not (and should not) know what all the possible shape objects are; otherwise, you could have just have used a switch statement within read().

The last ShapeContainer method computes the minimum distance of all the objects. Although we haven't yet dealt with selection, the last ShapeContainer method shows how the currently selected object can be found. Given a point, the distance to each graphics shape is calculated, and the closest shape is selected:

int ShapeContainer::distance(const FPoint& pp)
 {
    iterator p_selected = m_end;
    int d, minv = MANY;
    for (p = m_begin; p != m_end; ++p) {
      d = (*p)->distance(pp);
      if (minv < d) {
        minv = d;
        p_selected = p;
      }
    }
    if (p_selected != m_end) {
      //...do something with the selected object!
    }
    return minv;
 }

The window class ShapeWindow manages the user interface. Up to this point, you have not specifically needed the YAWL framework; you could hook turtle graphics into any framework of choice. The following example uses TGFrameWindow as a base class because it provides a standalone application frame window that supports turtle graphics:


const int NP = 60;

class ShapeWindow: public TGFrameWindow {
private:
   ShapeContainer m_shapes;
   Shape *m_obj;
   int m_count;
public:
   ShapeWindow()
   {  m_count = 0; m_obj = NULL; }

   void add_point(FPoint& pt, bool last_point)
   {
     static FPoint points[NP];
     if (m_obj == NULL) {
        message("Select an object type");
        return;
     }
     points[m_count++] = pt;

     if (m_count == m_obj->npoints() || last_point) {
      try {
          m_obj->create(points,m_count);
      }  catch(Error& e) {
          message(e.what().c_str());
          return;
      }
      m_shapes.add(m_obj);
      invalidate(NULL);
      m_obj = NULL;
      m_count = 0;
     }
   }

   void do_read()
   {
     TOpenFile dlg(this ,"Open Shape File");
     if (dlg.go()) {
        ifstream in(dlg.file_name());
        m_shapes.read(in);
     }
   }

   void do_write()
   {
     TSaveFile dlg(this ,"Save Shape File");
     if (dlg.go()) {
        ofstream out(dlg.file_name());
        m_shapes.write(out);
     }
   }


   void keydown(int vkey)                // override
   {
     switch(vkey) {
     case 'R': m_obj = new Rectangle(); break;
     case 'E': m_obj = new Ellipse();  break;
     case 'P': m_obj = new PolyLine(); break;
     case 'O': do_read();              break;
     case 'W': do_write();             break;
     default: message("not recognized");
     }
   }

   void tg_mouse_down(FPoint& pt)        // override
   {   add_point(pt,false); }

   void tg_right_mouse_down(FPoint& pt)  // override
   {  add_point(pt,true);  }
   void tg_paint(TG& tg)                 // override
   {   m_shapes.draw(tg);  }
};

The ShapeWindow class inherits all the behavior of an application frame window, but it overrides the painting, keyboard, and mouse methods. The user interface is very crude: For example, the user presses the R key for a new rectangle. A Shape object of the correct type is constructed and saved in the variable m_obj, although it isn't yet fully created.

You create the object by entering the points with the mouse; when the required number of points have been entered, you can call the create() method for the object, add the object to the shapes list, and refresh the window by calling invalidate(). In the case of polylines, an indefinite number of points can be entered, and you can right-click to add the last point. Both left and right mouse events are passed to add_point(), which keeps count of the number of points that have been entered and saves them into a static array of points. Because it's static, this array will keep its values after each call to add_point().

Finally, reading and writing is a simple matter of calling the appropriate file dialogs.

All the user interface information in this example is in ShapeWindow, and all the shape management is in ShapeContainer. These roles are kept distinct because as you add features to the program, you want to keep things as simple as possible. ShapeContainer was not necessary, but it made the program structure easier to understand.

Object-oriented designs are easy to extend. For example, consider what is involved in adding a new Shape object. It is possible to create a circle by choosing the bounding points carefully, but really you need another way of doing this. You should instead construct a circle by specifying the center and a point on the radius, which for simplicity we'll assume is along a horizontal line. The Circle shape object is obviously just an Ellipse shape object, but it has a different create() method, which rearranges the points and passes them to Ellipse::create(); this is a case where the fully qualified name is necessary. Otherwise this create() will call itself indefinitely!

class Circle: public Ellipse {
 public:
  Circle() { }
  static Shape *New() {  return new Circle(); }
  int type()          {  return 4; }

  void create(FPoint pts[], int sz)
  {
    FPoint p1 = pts[0], p2 = pts[1];
    double radius = p1.x - p2.x;
    pts[0].x = p1.x - radius;
    pts[0].y = p1.y - radius;
    pts[1].x = p1.x + radius;
    pts[1].y = p1.y + radius;
    Ellipse::create(pts,2);
  }
 };

Notice how simple it was to create a new shape class by using inheritance! There are now only two modifications:

Sc.add_shape(4,Circle::New);  // in setup_shapes()
...
// in ShapeWindow::keydown.
case 'C': m_obj = new Circle(); break;

This is all quite straightforward; the ShapeWindow class must be modified, but ShapeContainer needn't be. The object-oriented version is perhaps more work up front, but the payoff is that the resulting program is easy to extend. Figure 8.6 shows the class hieararchy for this case study.

Figure 8.6. Class hierarchy for the drawing program.


Some suggestions for further work:

  • Introduce color into these shapes, for both the foreground and the background.

  • Explain how selection of objects could work.

  • Explain what would be needed to have true composite shapes. That is, how can ShapeContainer itself be treated as a full-grown Shape?

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.227.26.217