C++ Abstraction Mechanisms

Posted on
Programming Classes Copy&Move Templates C++

I have been reading Bjarne Stroustrup’s “A tour of C++” recently and wanted to make notes on the different types of abstraction mechanisms available to the programmer. Below are my notes.

Concrete Classes

The basic idea of Concrete Classes is that they behave just like built-in types. It’s representation is part of it’s definition. This allows implementations to be optimally efficient in time and space. It defines the behavior of it’s member functions in full.

It allows us to:

  • Place objects of concrete types on the stack, in statically allocated memory and in other objects.
  • Refer to objects directly, and not just through pointers or references.
  • Initialize objects immediately and completely. (Using Constructors)
  • Copy objects.

Containers

To implement efficient concrete classes, short, simple function definitions must be inlined, this means there will be no jump calls in the assambly translation of the function. To help accomplish this goal, functions defined in the class, are inlined by default.

Concrete classes have constructors and destructors, which are function calls to ensure correct initialization and deletion of a concrete class. The constructor allocates some memory using the new operator and the destructor deallocates memory using the delete operator. The technique of doing this is called RAII (Resource Aquisition Is Initialization). This allows us to prevent having reserved stacks of memory stored for future or past program requirements and optimize the program’s memory usage.

Initializing Containers

There are many ways of container initialization, but there are a few favorites:

  • initializer_list constructor: Initialize with a list of elements
  • push_back(): Add a new element at the end (At the back of the sequence)

Initializer_list Constructor

#include <algorithm>
#include <iostream>
#include <initializer_list>  // std::initializer_list

class MyClass
{
  public:
    MyClass();
    MyClass(std::initializer_list<int>);
    void printList();
  private:
    int* list;
    int size;
};

MyClass::MyClass(std::initializer_list<int> lst) :list{new int[lst.size()]}, size{(int) lst.size()} {
  std::copy(lst.begin(), lst.end(), list);
}

void MyClass::printList(){
  for(auto i = 0; i < size; ++i){
    std::cout << list[i] << std::endl;
  }
}

int main(int argc, char *argv[])
{
  MyClass newClass = MyClass {1, 2, 3, 4, 5};
  newClass.printList();
  return 0;
}

Push_back() implementation

The given programmer implements a custom method to rearrange the object’s data. Imagine a class Vector, in which we can push_back() any given value. This enables us to dynamically change the Vector’s data array as more values are being push_back()ed.

Abstract types

An abstract type completely insulates a user from implementation details. To do this, we must decouple the interface from it’s representation and give up genuine local variables. Since we don’t know anything about the representation of an abstract type (Not even it’s size) we must allocate objects on the free store and access then through reference or pointers.

class Container{
  public:
    virtual double& operator[](int) = 0;  // Pure virtual function
    virtual int size() const = 0;         // const member function 
    virtual ~container() {};              // Destructor
};

The word virtual means: may be redefined later in a class derived from this one.

This class is a interface to a container defined later. A class derived from this one, provides an implementation for the container interface. The = 0 syntax means the function is pure virtual; That is some class derived from Container must define the function.

Therefore it is not possible to define a object that is just a Container. A Container can only serve as a interface for a class that implements it’s operator[]() and size() functions. A class with a pure virtual function is called an Abstract class.

A Container can be used like this:

void use(Container& c){
  const int sz = c.size();
  for(int i = 0; i < sz; ++i){
    cout << c[i];
  }
}

In the previous example, use() uses the container interface without previous knowledge of the container’s implementation.

A class that provides a Interface to a variety of other classes is often called a Polymorphic type.

Abstract classes don’t have constructors because it doesn’t have variables to initialize; It does have a destructor because the programmer doesn’t know what resources are owned by it’s implementation.

A example implementation of the abstract class Container may be:

class Vector_Container public Container {
  Vector v;
  public:
    Vector_Container(int s) :v{s} {};
    ~Vector_Container();
    double& operator[](int i){ return V[i]; }
    int size() const { return v.size(); }
};

The public can be read as: Is derived from. The destructor ~Vector_Container() overrides base class destructor Container() and implicitly calls it. The use of based and derived classes is commonly referred to as Inheritence.

The advantages of abstract classes is that functions like use() don’t need to know about specific classes derived from Container, it can use all of them, because it can be sure it’s specific methods have been overridden by the child class implementations.

Virtual functions

The Container function stores a table of pointer-to-methods in order for the Container object to know which of it’s derived classes method overrides to call. This table is called the vtbl (Virtual Function Table). Each class with virtual functions has it’s own vtbl identifying it’s virtual functions.

Class Hierarchies

A class hierarchy is a set of classes ordered by lattice created by derivation (public). They are used to represent objects that have a hierarchical representation between them.

Using virtual functions, we can create complex, multi-hierarchical relationships between objects. For example, a smiley face that is derived from class circle, which in turn is derived from class shape.

A virtual destructor is essential for a abstract class because an object of a derived class may be deleted through a pointer to a base class. If this happens, the virtual function call mechanism ensures that the proper destructor is called. That destructor then implicitly invokes the destructors of it’s bases and members.

A class hierarchy offers two kinds of benefits:

  • Interface Inheritance
    • A object of a derived class can be used wherever a object from a base class is required.
  • Implementation Inheritance
    • A base class provides functions or data that simplify the implementation of the derived class.

Functions returning pointers to newly created containers are dangerous, because the user might forget to delete the resources. To prevent this, we can use unique_ptr instead of plain naked pointers.

Copy and Move

By default, objects can be copied. The default meaning of copy is member-wise copy.

  • For simple concrete class: member-wise copy is often correct.
  • For sophisticated concrete class: member-wise copy is not the right semantics for copy.
  • For abstract types: Almost never is.

A example of a copy constructor and copy assignment can be viewed next:


// Copy constructor
Vector::Vector(const Vector& a);
// Copy assignment
Vector& Vector::operator=(const Vector& a);

// Copy constructor
Vector::Vector(const Vector& a)
  :sz{a.sz}
{
  elem = new double[sz];
  for(int i = 0; i < sz; ++i){
    elem[i] = a.elem[i];
  }
}

// Copy assignment
Vector& Vector::operator=(const Vector& a){
  double* tmp = new double[a.sz];
  for(int i = 0; i < a.sz; ++i){
    tmp[i] = a.elem[i];
  }
  delete[] elem;
  elem = tmp;
  sz = a.sz;
  return *this;
}

Imagine we want to perform a arithmetic operation on our vectors. If we want to add two vectors together, we could create a new vector to store the resulting values of the operation. V_res[i] = a[i] + b[i]; The problem with this approach is that the overload of the + operator creates a new vector to store the results, but after this, we are copying it into V_res, therefore wasting useful resources in creating a vector to store the resulting values and never using it again. To prevent this, we can move a vector rather than *copy*ing it.

An example of a move operator:

// Move constructor
Vector::Vector(Vector&& a);
// Move assignment
Vector& Vector::operator=(Vector&& a);

Vector::Vector(Vector&& a){
  elem = a.elem;
  sz = a.sz;
  a.elem = nullptr;
  a.sz = 0;
}

The compiler will choose the move constructor to implement the transfer of the return value out of the function. This means that a = b + c + d; will involve no copying of vectors. Instead, vectors are just moved.

A move constructor allows an object to move simply and cheaply from one scope to another.

The && means rvalue reference. rvalue roughly means a value that you can’t assign to, such as an integer returned from a function call. rvalue reference means a reference to a value that nobody else can assign to.

A move operation is applied when a rvalue reference is used as an initializer or as the right-hand side of an assignment.

We can use these operators like such:

Vector f(){
  Vector x(1000);
  Vector y(1000);
  Vector z(1000);

  z = x; // Copy operator
  y = std::move(x); // Move operator
  return z; // Move operator
}

By the time we return from f(), we will have moved z’s values to a lvalue and destroyed x, y and z.

Resource management

It is common to use resource handlers such as vectors and threads rather than pointers in many cases.

Deleting copy and move operators.

Using constructors, destructors and copy and move operations, we allow great control over containers to the programmer, but sometimes, we don’t want to be able to copy or move a container.

Using the default copy or move for a class is typically a disaster because we don’t know what members the derived class has, so the best way to prevent this is to simply delete the default copy and move operations.

// No copy operations
Shape::Shape(const Shape&) = delete;
Shape& Shape::operator=(const Shape&) = delete;

// No move operations
Shape::Shape(Shape&&) = delete;
Shape& Shape::operator=(Shape&&) = delete;

Templates

A template is a class or function that we parameterize with a set of types or values.

We use templates to represent concepts that are best understood as something very general from which we can generate specific types ans functions by specifying arguments such as the element type double.

Parameterized types

We can generalize our Vector class by making it a template and replacing the specific type double with a parameter.

If we want to use the range-for loop for our vector, we must define suitable begin() and end() functions like so:

template<typename T>
T* begin(Vector<T>& x){
  return &x[0];
}

template<typename T>
T* end(Vector<T>& x){
  return x.begin()+x.size(); // Pointer to one-past-last element
}

//Now we can use these to write range-for

void f2(const Vector<string>& vs){
  for(auto s : vs){
    std::cout << vs[s] << std::endl;
  }
}

Function templates

We can create functions that accumulate the values of any container type:

template<typename C, typename V>
Value sum(const C& c, V v){
  for(auto x : c) v+=x;
  return v;
}

void use(Vector<int>& vi, std::list<double>& ld, std:vector<complex<double>>& vc){
  int x = sum(vi, 0);
  double d = sum(vi, 0.0);
  double dd = sum(ld, 0.0);
  auto z = sum(vc, Complex<double>{}); // The sum of a vector of complex<double>
}

Function objects

One particularly useful kind of template is the function object or functor which is used to define objects that can be called like functions.

template<typename T>
class Less_than {
  const T& val;
  public:
    Less_than(const T& v) :val(v) {}
    bool operator()(const T& x) const {return x < val;} // Call operator
};

We can define named variables of type Less_than and call them afterwards.

Less_than<int> lti {42}; // Will compare to 42 using < operator
Less_than<string> lts {"Bjarne"}; // Will compare to Bjarne using < operator

void fct(int n, const string& s){
  bool b1 = lti(n);
  bool b2 = lts(s);
}

Functors are widely used as arguments to algorithms:

// Count the occurences of values for which a predciate returns true

template<typename C, typename P)
int count(const C& c, P pred){
  int cnt = 0;
  for(const auto& x : c){
    if(pred(x)) ++cnt;
  }
  return cnt;
}

void f(const Vector<int>& vec, const list<string>& lst, int x, const string& s) {
  std::cout << "Number of values less than: << x << ": "
            << count(vec, Less_than<int>{x}) << std::endl;
  std::cout << "Number of values less than: << s << ": "
            << count(lst, Less_than<string>{s}) << std::endl;
}

In the above example, Less_than<int>{x} constructs a object for which the call operator compares to the int called x. The beauty of these function objects is that they carry the value to be compared against along with them. Their ability to carry data plus their efficiency makes function objects particularly useful as arguments to algorithms.

Function objects that are used to specify the meaning of key operations of a general algorithm (count(), Less_than()) are often referred to as policy objects.

Defining function objects separately from it’s use can be seen as a inconvenient, therefore there is a notation that allows in-line definition. These are called lambdas. A example of a lambda function that does exactly the same as de definition and calling of Less_than is: [&](int a){ retur n a<x; }.

Variadic templates

A template that accepts arbitrary number of arguments of arbitrary types.

The key to implementing a variadic template is that when you pass it a list of arguments, you can separate the first argument from the rest.

template<typename T, typename... Tail>
void f(T head, Tail... tail){
  g(head);    // Do something to head
  f(tail...); // try again with tail (Recursively)
}

void f() {}

template<typename T>
g(T x){
  cout << x << ", ";
}

int main(){
  f(1, 1.1, "Hello"); // Will print "1, 1,1, Hello"
}

This would call f(1, 1.1, "Hello"), then f(1.1, "Hello"), f("Hello"), and finally f().

It is important to realize that there has to be specific type checking implemented with variadic templates. In this example, this would happen in g().