Introduction to C + + language Chapter 4 - class 4.5 level

Posted by teo99 on Wed, 02 Mar 2022 08:20:28 +0100

4.5 level

Container is a very simple example of class hierarchy. The so-called class hierarchy refers to a group of classes created by derivation (such as public), which are arranged in order in the lattice. We use class hierarchy to represent concepts with hierarchical relationship, such as "fire truck is a kind of truck, truck is a kind of vehicle" and "smiling face is a circle, and circle is a shape". In practical application, huge class hierarchy is very common, which often contains hundreds of classes, both in depth and width. However, in this section, we only consider a semi real example, that is, the shape on the screen.


Arrows indicate inheritance relationships. For example, the class Circle derives from the class Shape. In order to express this simple hierarchical relationship in code, we need to first declare a class that defines the general properties of all shapes:

class Shape{
public:
	virtual Point center() const = 0;		//Pure virtual function
	virtual void move(Point to) = 0;
	virtual void draw() const = 0;			//Paint on the current canvas
	virtual void rotate(int angle) = 0;
	virtual ~Shape(){}						//Destructor
	//...
};

This interface is naturally an abstract class: for each Shape, their representation is different (except for the position of the vtbl pointer). Based on the above definition, we can write a function to manipulate the vector of the Shape pointer:

void ratate_all(vector<Shape*>& v, int angle)	//Rotate the element of v by an angle
{
	for(auto p : v)
		p->rotate(angle);
}

In order to define a specific Shape, you must first specify that it is a Shape, and then specify its unique properties (including virtual functions):

class Circle : public Shape{
public:
	Circle(Point p, int rad);	//Constructor
	Point center() const override
	{
		return x;
	}
	void move(Point to) override
	{
		x = to;
	}
	void draw() const override;
	void rotate(int) override{}	//A simple and clear example algorithm
private:
	Point x;		//center of a circle
	int r;			//radius
};

So far, the examples of Shape and Circle are similar to those of Container and vector_ Compared with the Container example, it does not involve anything new, but we can continue to construct:

class Smiley : public Circle{
public:
	Smiley(Point p, int rad) : Circle{p, r}, mouth{nullptr}{}
	~Smiley()
	{
		delete mouth;
		for(auto p : eyes)
			delete p;
	}
	void move(Point to)override;
	void draw() const override;
	void rotate(int) override;
	void add_eye(Shape* s);
	virtual void wink(int i);		//Blinks i
	//...
private:
	vector<Shape*> eyes;			//Usually have two eyes
	Shape* mouth;
};

Member function push of Vector_ Back() copies its parameters into the Vector (here is eyes) to become its last element, and increases the length of the Vector by 1.

Now you can define Smiley::Draw() by calling draw() of Smiley's base class and draw() of its members:

void Smiley::Draw() const
{
	Circle::draw();	
	for(auto p : eyes)
		p->draw();
	mouth->draw();
}

Notice how smiley saves its eyes in a standard library vector and releases them in the destructor. Shape's destructor is a virtual function, which is covered by Smiley's destructor. For abstract classes, because the objects of their derived classes are usually manipulated through the interface of the abstract base class, there must be a virtual destructor in the base class. In particular, we may use a base class pointer to release derived class objects. In this way, the virtual function call mechanism can ensure that we call the correct destructor, and then the destructor implicitly calls the destructor of its base class and its members.

In the simple example above, the programmer is responsible for properly placing the eyes and mouth in the circle representing the face.

When we define a new class by derivation, we can add data members and new operations to it. On the one hand, this mechanism provides great flexibility, which may also lead to confusion and poor design.

4.5.1 benefits of hierarchy

The benefits of class hierarchy are mainly reflected in two aspects:

  • Interface inheritance: derived class objects can be used wherever base class objects are required. That is, the base class acts as a derived class interface. Container and Shape are good examples. Such classes are usually abstract classes.
  • Implementation inheritance: the base class is responsible for providing functions or data that can simplify the implementation of derived classes. Smiley uses Circle's constructor and Circle::draw() as examples. Such base classes usually contain data members and constructors.

Concrete classes, especially those representing simple classes, are very similar to built-in types: we define them as local variables, access them by their names, copy them at will, and so on. Classes in the class hierarchy are different: we tend to allocate space for them in free storage with new, and then access them through pointers or references. For example, we design a function that first reads the data describing the Shape from the input stream, and then constructs the corresponding Shape object:

enum class Kind{circle, triangle, smiley};
Shape* read_shape(istream& is)	//Read shape description information from input stream is
{
	//... Read the shape description information from is and find the shape category k
	switch(k){
	case Kind::circle:
		//Read circle data {Point, int} to p and r
		return new Circle{p, r};
	case Kind::triangle:
		//Read triangle data {Point, Point, Point} to p1, p2 and p3
		return new Triangle{p1, p2, p3};
	case Kind::smiley:
		//Read smiley data {Point, int, Shape, Shape, Shape} to p, r, e1, e2 and m
		Smiley* ps = new Smiley{p, r};
		ps->add_eye(e1);
		ps->add_eye(e2);
		ps->set_mouth(m);
		return ps;
	}
}

The program uses this function as follows:

void user()
{
	std::vector<Shape*> v;
	while(cin)
		v.push_back(read_shape(cin));
	draw_all(v);		//Call draw() on each element
	rotate_all(v, 45);	//Call rotate(45) for each element
	for(auto p : v)		//Remember to delete the element
		delete p;
}

Obviously, this example is very simple, especially without any error handling, but it vividly shows that user() has no idea what Shape it manipulates. The code of user() only needs to be compiled once to use the new Shape subsequently added to the program. Note that there are no pointers to these shapes outside user(), so user() should be responsible for releasing them. This is done by the delete operator and depends entirely on the Shape's virtual destructor. Because the destructor is a virtual function, delete will call the destructor of the lowest derived class. This is critical because the derived class may have acquired many resources (such as file handles, locks, output streams, etc.) that need to be released. In this example, Smiley releases its eyes and mouth objects. Once it has done this, it continues to call the destructor of Circle. The construction of objects is carried out by the constructor "top-up" (base class first), and the destruction is carried out by the destructor "bottom-up" (derived class first).

4.5.2 hierarchical roaming

read_ The Shape() function returns a Shape * pointer so that we can handle all shapes in a similar way. However, if we want to use a member function provided only by a specific derived class, such as Smiley's wink(), we can use dynamic_ The cast operator asks, "is this Shape smiley?"

Shape* ps{read_shape(cin)};
if(Smiley* p = dynamic_cast<Smiley*>(ps)){	//...  Does ps point to a Smiley
	//... It's Smiley. Use it
}
else{
	//.. Is not Smiley, perform other operations
}

If dynamic at run time_ If the type of the object pointed to here is smiley or the type pointed to here is not smiley_ Cast returns nullptr.
If we think that pointers to objects of different derived classes are legal parameters, we can use dynamic for pointer types_ Cast, and then check whether the result is nullptr. This check is often conveniently used in variable initialization in conditional statements.

If we cannot accept different types, we can simply use dynamic for reference types_ cast. If the object is not of the expected type, dynamic_cast will throw a bad_cast exception:

Shape* ps{read_shape(cin)};
Smiley& r{dynamic_cast<Smiley&>(*ps)};	//To catch STD:: bad somewhere_ cast

Moderate use of dynamic_cast can make the code more concise. If we can avoid using type information, we can write more concise and efficient code, but type information is occasionally lost and must be recovered. A typical scenario is that we pass an object to a system, which accepts the interface defined by the base class. When the system returns the object later, we may have to restore its original type. Similar to dynamic_ The operation of cast is called "is kind of" or "is instance of" operation.

4.5.3 avoid resource leakage

Experienced programmers may have noticed that I left three possible errors in the above program:

  • The implementer of Smiley may have failed to delete the pointer to mouthd.
  • read_ The consumer of shape() may not be able to delete the pointer returned by.
  • The owner of the Shape pointer container may not be able to delete the object pointed to by the pointer.

In this sense, pointers to objects allocated on free storage are dangerous: we should not use a "plain old pointer" to represent ownership. For example:

void user(int x)
{
	Shape* p = new Circle{Point{0,0}, 10};
	//...
	if(x<0)throw Bad_x{};	//Potential leakage hazard
	if(x==0)return;		//Potential leakage hazard
	//...
	delete p;
}

Unless x is a positive number, this code will leak. Giving the result of new a "bare pointer" is asking for trouble.

A simple solution to this problem is to use the standard library unique instead of the "bare pointer" if resources need to be released_ PTR (see section 13.2.1):

class Smiley : public Circle{
	//...
private:
	vector<unique_ptr<Shape>> eyes;	//Usually have two eyes
	unique_ptr<Shape> mouth;
};

This is an example of a simple, universal and efficient resource management technology (see section 5.3).

This change has a pleasant side effect. We no longer need to define a destructor for Smiley. The compiler will implicitly generate a destructor that will_ PTR (see section 5.3) performs the required deconstruction operations. Using unique_ The code of PTR has exactly the same efficiency as that of using bare pointers correctly.

Now let's consider read_ User of shape():

unique_ptr<Shape> read_shape(istream& is)	//Read shape description information from input stream is
{
	//... Read the shape description information from is and find the shape category k
	switch(k){
	case Kind::circle:
		//Read circle data {Point, int} to p and r
		return unique_ptr<Shape>{new Circle{p, r}};	//See section 13.2.1
	//...
}
void user()
{
	vector<unique_ptr<Shape>> v;
	while(cin)
		v.push_back(read_shape(cin));
	draw_all(v);		//Call draw() on each element
	rotate_all(v, 45);	//Call rotate(45) for each element
}//All shapes are implicitly destroyed

Now each object is represented by unique_ptr has, when the object is no longer needed, in other words, when the object is unique_ When PTR leaves the scope, unique_ptr will delete the object.

To make unique_ The PTR version of user() can run correctly. We need to be able to accept vector < unique_ Draw of PTR >_ All () and rotate_all(). Write too much like this_ The all() function is too tedious, so section 6.3.2 provides an alternative technique.

Topics: C++