CSE 374, Lecture 23: C++ Classes + Inheritance

Stack vs heap

Just like in C, we can create objects on either the stack or the heap in C++. If we use the keyword "new", that means the object will be on the heap.

    String s1("foo");              // on the stack
    String s2 = new String("bar"); // on the heap

BankAccount example

We wrote a BankAccount class which helped illustrate a few more things about classes in C++:

Inheritance

As we discussed last lecture, one of the keystons of object-oriented programming is inheritance, or the sharing of behavior from a "base class" to a "derived class" (this is the terminology we use in C++). We did a demonstration of how this works by writing a SavingsAccount class.

    class SavingsAccount : public BankAccount {
     public:
      SavingsAccount(double interestRate, std::string name);

      double getInterestRate() const;

      // Deducts the amount from this account, but throws a runtime_error if there
      // are no more available transactions for the month.
      virtual void withdraw(int amount) override;

     private:
      double interestRate_;
      int numTransactionsInMonth_;
    };

Mystery

Consider the following mystery problem: what does the program print?

    #include <iostream>
    using namespace std;
    class A {
     public:
      A() { cout << "a()" << endl; }
      ~A() { cout << "~a" << endl; }
      void m1() { cout << "a1" << endl; }
      void m2() { cout << "a2" << endl; }
    };

    class B : public A {
     public:
      B() { cout << "b()" << endl; }
      ~B() { cout << "~b" << endl; }
      void m1() { cout << "b1" << endl; }
      void m2() { cout << "b2" << endl; }
    };

    int main() {
      B* x = new B();
      x->m1();
      x->m2();
      delete x;
    }

The output:

    a()
    b()
    b1
    b2
    ~b()
    ~a()

We see here another fact about inheritance: constructors and destructors EXTEND, instance functions OVERRIDE. When we created a new B, the A() constructor was called AUTOMATICALLY before the B() constructor was called, just as the destructor for A was called automatically after the destructor for B. The base class always bookends the constructor/destructor call. The instance functions (m1/m2), on the other hand, do NOT extend, but replace the behavior in the base class.

If we modify the variable type of X, does anything change?

    int main() {
      A* x = new B();
      x->m1();
      x->m2();
      delete x;
    }

x is now of type A, which is allowed because B extends A. Let's see the output. We would expect that the output would be the same as before.

    a()
    b()
    a1
    a2
    ~a()

What happened?!? We see the constructors are called as expected, but then the rest of the functions are just A's functions, not B's! But the object was of type B.

Function table

We said that objects are DATA and BEHAVIOR. We know how to represent data - it's basically the same as structs in C - but how do objects have behavior? Where in memory do those behaviors live?

One option is that the code for the behavior lives in the object itself. So in memory, the object x might be represented as

    int m
    int n
    code for m1
    code for m2

with the code stored as if it was data in the object itself. However this is not optimal because if this is the case, then EVERY OBJECT has a COPY of the code inside of it - we're wasting a lot of memory, because for a given class, the code will be the same for all objects of that class.

Instead, we could store pointers to code!

    int m
    int n
    pointer to m1's code
    pointer to m2's code

Now each object has just a pointer to the code, which is much smaller than the code itself. But still, this isn't optimal! We are repeating all the pointers for all the functions in every object; what if there are many functions? That will be a lot of pointers. Instead, we can take advantage of the fact that for a particular class, all of the functions will be the same. We can have a single "function table" that stores the functions for the class, and then for a particular object we will look up the code in the appropriate function table.

Back to the mystery.

Now we can begin to understand why the mystery did not perform as expected. When the variable was of type A, we looked up the function table for type A and called the function of A.

However, we really want to execute B's m1 and m2 regardless of the type of the variable storing the object! How can we do this? C++ adds the "virtual" keyword which is a special signal in the function table that indicates "hey, even if the variable is of type A, you really need to go look at the function table of the ACTUAL OBJECT TYPE and not the variable." The virtual specifier is essentially a flag that modifies the act of resolving the function in the function table. We can fix our example like so:

    #include <iostream>
    using namespace std;
    class A {
     public:
      A() { cout << "a()" << endl; }
      virtual ~A() { cout << "~a" << endl; }
      virtual void m1() { cout << "a1" << endl; }
      virtual void m2() { cout << "a2" << endl; }
    };

    class B : public A {
     public:
      B() { cout << "b()" << endl; }
      virtual ~B() { cout << "~b" << endl; }
      virtual void m1() override { cout << "b1" << endl; }
      virtual void m2() override { cout << "b2" << endl; }
    };

Now it will output what we expect:

    a()
    b()
    b1
    b2
    ~b()
    ~a()

Destructors should ALWAYS be declared virtual. Why? Because if there is any data that needs to be cleaned up in a derived class, it is VITAL that the derived class's constructor is called regardless of the type of the variable.

The proper terminology for what we've just described is the "virtual function table", and the act of looking up which code to execute is called "dynamic dispatch".

Other things

We talked briefly about the "auto" keyword and lambdas; look into these more on your own if you are interested. Modern C++ has a ton of extensions.