Lecture 10

Last Time: Git, UML, Inheritance This Time: virtual, override, pure virtual, destructors

How do we know which method call in the hierarchy is invoked for b.isHeavry() or b->Heavy()? (Notes copied into Virtual Method)

  1. Is the method being called on an object? If so, always use the static type to determine which method is called.
Book b{...}; // b.isHeavy() -> calls Book::isHeavy()
Text t{...}; // t.isHeavy() -> calls Text::isHeavy()
 
Book b = Text{...};
b.isHeavy(); // calls Book::isHeavy()
  • First example: In the first example, b.isHeavy(), the object b is of type Book, and since isHeavy() is a non-virtual method, the method called is determined by the static type of b, which is Book. Therefore, it calls Book::isHeavy().
  • If we call t.isHeavy(), exhibits dynamic dispatch. Dynamic dispatch, also known as runtime polymorphism, is the mechanism in C++ by which the appropriate method to be executed is determined at runtime based on the actual type of the object being referred to. This is achieved through the use of virtual functions and the virtual function table (vtable). When you call t.isHeavy() where t is an object of the Text class, the method that gets executed depends on the actual type of the object, which is Text in this case. Since isHeavy is declared as virtual in the base class Book and is overridden in the derived class Text, the method call is dynamically dispatched to the version of isHeavy defined in the Text class.
  • In the case of Book b = Text{...};, the isHeavy function call on b would indeed call the Book::isHeavy() method. This is because the dynamic type of b is Book even though it was originally assigned a Text object. This scenario demonstrates the principle of using the static type to determine which method is called. Text{...} creates a Text object. The Text object is used to initialize b, which is of type Book. This involves a process called object slicing, where only the base class part of the derived object is used to initialize the base class object. When b.isHeavy() is called, it’s based on the static type of b, which is Book. Since b is of type Book, the Book::isHeavy() method is called. Even though b was originally created as a Text object, the static type determines which method is called, and it calls the version of isHeavy defined in the Book class.

When a method is called on an object, the determination of which method to invoke is based on the static type of the object.

  1. Is the method called via pointer or reference? a. Is the method NOT declared as virtual? Use the static type to determine which method is called:
Book* b = new Text{...};
b->nonVirtual(); // calls Book::nonVirtual

b. Is the method virtual? Use the dynamic type to determine which method is called:

Book* b = new Text{...}; 
b->isHeavy(); // calls Text::isHeavy()

We can support:

vector <Book*> bookcase;
bookcase.push_back(new Book{...});
bookcase.push_back(new Text{...});
bookcase.push_back(new Comic{...});
 
for (auto book: bookcase){
	cout << book->isHeavy() << endl;
}

Each iteration calls a different isHeavy() method.

What about (*book).isHeavy()?

(*book),isHeavy() calls the correct version/method as well. Why? Because *book yields a Book& (i.e. a reference).

What is the purpose of the override keyword?

  • It has no effect on the executable that is created! (WHAT DOES THAT MEAN????????)
  • However, it can be helpful for catching bugs.
class Text {
...
	bool isHeavy();
}

isHeavy() is missing a const. This won’t override Book’s virtual isHeavy because the signatures do not match.

Specifying override will have the compiler warn you if the signature does not match a superclass’s virtual method.

Compiler will always choose to call a const method to guarantee optimization and correctness.

Why not just declare everything as virtual for simplicity?

Declaring doSomething as virtual doubles the size of our Vec object, program consumes more RAM, slower in general. This extra 8 bytes is storing the vptr - virtual pointer. vptr allows us to achieve dynamic dispatch with virtual functions.

struct Vec{
	int x, y;
	void doSomething();
}
 
struct Vec2{
	int x, y;
	virtual void doSomething();
}
 
Vec v{1,2};
Vec2 v{3,4};
 
cout << sizeof(v) << endl; // 8 bytes
cout << sizeof(v) << endl; // 16 bytes
  • Declaring doSomethin() as virtual doubles the size of our Vec object. Program consumes more RAM, slower in general.
  • This extra 8 bytes is storing the vptr - virtual pointer. vptr allows us to achieve dynamic dispatch with virtual functions.

Remember: In MIPS, function calls use the JALR instruction, it saves a register, jumps PC to a specific memory address, hardcoded in the machine instruction.

With dynamic dispatch, which function to jump to could depend on user input. Cannot be hardcoded.

struct Vec2{
	int x, y;
	virtual void doSomething();
}
 
struct Vec3:public Vec2{
	int z;
	void doSomething() override;
}
 
string choice;
cin >> choice;
Vec2* v;
 
if(choice == "vec2") v = new Vec2{...};
else v = new Vec3{...};
v->doSomething();

Depending on the dynamic type of the v vptr it will call Vec2 or Vec3’s doSomething().

When we create a Vec2 or Vec3, we know what type of object we’re creating, so we can fill in the appropriate vptr for that object. vptr always points to the vtable which points to the function address.

Now, in either case, we can simply follow the vptr, get to the vtable, and find the function address for the doSomething() method.

Extra running time cost in the time it takes to follow the vptr and access the vtable.

C++ philosophy: Don’t pay for costs unless you ask for it.


Destructors Revisited
class X{
	int* a;
	public:
		X(int n): a{new int[n]}
		~X() {delete[] a;}
};
 
class Y:public X{
	int* b;
	public:
		Y(int n, int m): x{n}, b{new int[m]}
		~Y() {delete[] b;}
};
 
X x{5};
X* px = new x{5};
Y y{5,10};
Y* py = new Y{5,10};
X* pxy = new Y{5,10}
delete px; delete py; delete pxy;

Which of these leaks memory? Because the destructor is non-virtual, for pxy, we invoke ~X , not the ~Y, so this array b is leaked, since the Y object does not get destroyed.

Solution: declare virtual ~X();, so delete pxy will call ~Y(). Unless you are sure a class will never be subclassed, then always declare you destructor virtual. If you are sure, enforce it via the final keyword.

class X final{
	...
}

Now, the program won’t compile if anyone tries to subclass it.

Object destruction sequence:

  1. Destructor body runs
  2. Object fields have their destructors run in reverse declaration order
  3. Superclass destructor runs
  4. Spare is reclaimed

Next: CS247 Lecture 11