License : Creative Commons Attribution 4.0 International (CC BY-NC-SA 4.0)
Copyright : Frédéric Pennerath, CentraleSupelec
Last modified : April 19, 2024 10:22
Link to the source : generic-programming.md

Table of contents

Generic programming: the basic facts

What is generic programming?

What are the main advantages of Generic Programming over OOP?

Solutions based on templates are generally more:

What are the limitations of GP?

One could ask why GP and OOP are complementary if GP has only advantages over OOP. This is because the genericity of GP is limited to compilation time whereas OOP can bring polymorphism at runtime, thanks to virtual functions.

Why is it important to understand principles of generic programming?

But wait, C++ is not the only language to have generic data structures?

In weakly typed interpreted languages like Python, you can even store values of any types in the same list (which is indeed a dynamic array or vector):

L = [ 1, 3.1415 ]
L.append('blah blah')

Even in strongly typed compiled languages like Java, you can write:

import java.util.Vector;

Vector v = new Vector();
v.add(1);     // Actually rewritten as v.add(new Integer(1));
v.add(true);  // Actually rewritten as v.add(new Boolean(true));

You can even have strong typing thanks to generics that mimic syntactically C++ templates:

import java.util.Vector;

Vector v = new Vector<Integer>();
v.add(1);     // Actually rewritten as v.add(new Integer(1));
v.add(true);  // Fail at compilation

Yes, ok but despite appearances, this is not generic programming, this is object oriented programming.

The real picture for Python, Java and most high-level languages look like the following object diagram:

false genericity in Python

In comparison, templates in C++ produce much more efficient implementations:

false genericity in Python

The example of computing the Greatest Common Divisor (GCD)

Let’s consider an example to illustrate the previous statements about the difference between GP and OOP. Consider Euclid’s algorithm that computes the greatest common divisor (GCD) of two integers.

Let’s recall its principle by manually running it to compute gcd(54,174):

 174 = 54 x 3 + 12    ->   gcd(174,54) = gcd(54,12)
  54 = 12 x 4 +  6    ->   gcd(54,12)  = gcd(12,6)
  12 =  6 x 2 +  0    ->   gcd(12,6)   = gcd(6,0) = 6
int gcd(int a, int b) {
  while(b != 0) {
    int r = a - b *(a/b);  // Compute the remainder of a divided by b
    a = b; // gcd(a,b) is equal to gcd(b,r) !
    b = r;
  }
  return a; // When b = 0, gcd(a,b) is equal to a
}

While given for integers, this algorithm can be generalized to any commutative ring like the ones of polynomials, Gaussian integers, etc.

How can we rewrite the gcd function so that Euclid’s algorithm can be applied to any type of ring.

Two solutions exist:

The solution based on Object-Oriented Programming

How could we write a generic Euclid algorithm in Python or Java, based on OOP?

Step 1: defining the classes hierarchy

The main idea is to use inheritance and virtual methods.

solution based on inheritance

One introduces an abstract class RingElt and we leverage runtime polymorphism using virtual methods.

Step 2: we replace int with RingElt and we introduce identity()

class RingElt {
public:
  static RingElt gcd(RingElt a, RingElt b) {
    while(b != RingElt::identity()) {
      RingElt r = a - b * (a/b);
      a = b;
      b = r;
    }
    return a; 
  }
  
  static virtual const RingElt& identity() const = 0;
  ...
};

class Integer : public RingElt {
  int i_;
  static const Integer identity_;

public:
  Integer(int i) : i_(i) {}
  static const RingElt& identity() const override { return identity_; }
  ...
};

const Integer Integer::identity_ = Integer(0);

Issues

Virtual methods, a reminder

virtual table principle

Step 3: removing virtual static members

class RingElt {
  static RingElt gcd(RingElt a, RingElt b) {
    while(b != b.identity()) {
      RingElt r = a - b * (a/b);
      a = b;
      b = r;
    }
    return a; 
  }
  virtual const RingElt& identity() const = 0;
  ...
};
class Integer : public RingElt {
  ...
  const RingElt& identity() const override { return identity_; }
  ...
};

Issues

Step 4: cloning objects

Assignment of the underlying concrete objects (i.e. Integer or Polynomial) can be achieved using object cloning:

static RingElt* gcd(const RingElt& ra, const RingElt& rb) {
  RingElt *a = ra.clone(), *b = rb.clone();
  while(*b != b->identity()) {
    RingElt* r = a->clone();
    *r -= *b * (*a / *b);
    a = b;
    b = r;
  }
  return a; 
}

class RingElt {
public:
  static RingElt* gcd(const RingElt&, const RingElt&);
  virtual const RingElt& identity() const = 0;
  virtual RingElt* clone() const = 0;
};

class Integer : public RingElt {
  int i_; ...
public:
  Integer(int i) : i_(i) {}
  ...
  virtual RingElt* clone() {
    return new Integer(i_);
  }
  ...
};

Issues

Step 5: fixing memory leaks

static RingElt* gcd(const RingElt& ra, const RingElt& rb) {
  RingElt *a = ra.clone(), *b = rb.clone();
  while(*b != b->identity()) {
    RingElt* r = a->clone();
    *r -= *b * (*a / *b);
    delete a;
    a = b;
    b = r;
  }
  delete b;
  return a; // This "a" is not released to memory!
}

int main() {
  Integer a(12), b(10);
  
  Integer* p = std::static_cast<Integer*>(RingElt::gcd(a,b));
  
  std::cout << "gcd(a,b) =" << p->i_ << std::endl;
  
  delete p; // Should not forget to desallocate result
}

Issues

Step 6: using smart pointers

We delegate memory desallocation to shared pointers:

static std::shared_ptr<RingElt> gcd(const RingElt& ra, const RingElt& rb) {
  std::shared_ptr<RingElt> a = ra.clone(), b = rb.clone();
  while(*b != b->identity()) {
    std::shared_ptr<RingElt> r = a->clone();
    *r -= *b * (*a / *b);
    a = b;
    b = r;
  }
  return a; 
}

class RingElt {
public:  
  static std::shared_ptr<RingElt> gcd(const RingElt&, const RingElt&);
  virtual const RingElt& identity() const = 0;
  virtual std::shared_ptr<RingElt> clone() const = 0;
};

class Integer : public RingElt {
  int i_; ...
public:
  Integer(int i) : i_(i) {}
  ...
  virtual std::shared_ptr<RingElt> clone() const override {
    return std::make_shared<Integer>(i_);
  }
  ...
};

int main() {
  Integer a(12), b(10);
  
  std::shared_ptr<Integer> p = std::static_pointer_cast<Integer>(RingElt::gcd(a,b)); // Still a cast
  
  std::cout << "gcd(a,b) =" << p->i_ << std::endl;
  
  // No more delete here
}

Issues

Step 7: virtual operators

static std::shared_ptr<RingElt> gcd(const RingElt& ra, const RingElt& rb) {
  std::shared_ptr<RingElt> a = ra.clone(), b = ra.clone();
  while(*b !=(b->identity())) {
    std::shared_ptr<RingElt> r = a->clone();
    *r -= *b * (*a / *b);
    a = b;
    b = r;
  }
  return a;
} 

class RingElt {
public:
  static std::shared_ptr<RingElt> gcd(const RingElt&, const RingElt&);
  virtual const RingElt& identity() const = 0;
  virtual std::shared_ptr<RingElt> clone() const = 0;
  virtual bool operator!=(const RingElt&) const = 0;
  virtual RingElt& operator-=(const RingElt&) = 0;
  virtual RingElt& operator*(const RingElt&) const = 0;
  virtual RingElt& operator/(const RingElt&) const = 0;
};

Issues

RingElt& Integer::operator*(const RingElt& other) {
  Integer* product = new Integer(i_ * static_cast<const Integer&>(other).i_);
  return *product;
}
std::shared_ptr<RingElt> Integer::operator*(const RingElt& other) {
  return std::shared_ptr<RingElt>(new Integer(i_ * static_cast<const Integer&>(other).i_));
}

static std::shared_ptr<RingElt> gcd(const RingElt& ra, const RingElt& rb) {
  std::shared_ptr<RingElt> a = ra.clone(), b = ra.clone();
  while(*b != (b->identity())) {
    std::shared_ptr<RingElt> r = a->clone();
    *r -= *(*b  * (*(*a / *b))); // Yucky!!!
    a = b;
    b = r;
  }
  return a;
} 

Step 8: prefer assignment operators +=, -=, *=, etc

To avoid these extra memory allocations, assignement operators can be used to store results on existing objects. But then the code loses in clarity:

static std::shared_ptr<RingElt> gcd(const RingElt& ra, const RingElt& rb) {
  std::shared_ptr<RingElt> a = ra.clone(), b = ra.clone();
  while(*b != b->identity()) {
    std::shared_ptr<RingElt> r = a->clone();
    *a /= *b;
    *a *= *b;
    *r -= *a;
    a = b;
    b = r;
  }
  return a;
} 

class RingElt {
public:
  static std::shared_ptr<RingElt> gcd(const RingElt&, const RingElt&);
  virtual const RingElt& identity() const = 0;
  virtual std::shared_ptr<RingElt> clone() const = 0;
  virtual bool operator!=(const RingElt&) const = 0;
  virtual RingElt& operator-=(const RingElt&) = 0;
  virtual RingElt& operator*=(const RingElt&) = 0;
  virtual RingElt& operator/=(const RingElt&) = 0;
};

Step 9: implement classes Integer and Polynomial

class Integer : public RingElt {
  int i_; ...
public:
  ...
  bool operator!=(const RingElt& elt) const {
    const Integer& i = std::static_cast<Integer&>(elt); // Dangerous!!
    return i_ != i.i_;
  }
  
  RingElt& operator*=(const RingElt& elt) {
    const Integer& i = std::static_cast<const Integer&>(elt); // Dangerous!!
    i_ *= i.i_;
    return *this;
  }
  ...
};

Issues

Preliminary conclusions :

We have shown that OOP:

What about Generic Programming?

The solution based on Generic Programming

Let’s see how we can do the same using templates…

Step 1: transforming the initial algorithm into a function template

template<typename Ring>
Ring gcd(Ring a, Ring b) {
  while(b != Ring()) {
    Ring r = a - b * (a/b);
    a = b;
    b = r;
  }
  return a;
}

void main(int argc, char** argv) {
  std::cout << "gcd(a,b) = " << gcd(12,10) << std::endl;  // Identical to gcd<int>(12,10)
}

Notes

Step 2: code optimization

It is not because a code runs as expected that work is over…

template<typename Ring>
Ring gcd(Ring a, Ring b) {
  Ring e{}, r; 
  Ring *pa = &a, *pb = &b, *pr = &r;
  while(*pb != e) {
    *pr = *pa - *pb * (*pa / *pb);
    Ring* old_pa = pa;
    pa = pb; // Assignment of pointers instead of objects
    pb = pr;
    pr = old_pa;
  }
  return *pa;
}

Notes

Step 2 bis: code optimisation from C++11

template<typename Ring>
Ring gcd(Ring a, Ring b) {
  Ring e{}, r;
  while(b != e) {
    r = a - b * (a/b); // Ring(&&) is called here
    a = std::move(b); // and here
    b = std::move(r);
  }
  return a;
}

Notes

template<typename Scalar> class Polynomial : private std::vector<Scalar> {
public:  
  // Copy constructor
  Polynomial(const Polynomial& other):
    std::vector<Scalar>(other) {}
    
  // Move constructor
  Polynomial(Polynomial&& other) :
    std::vector<Scalar>(std::move(other)) {}
  ...
};

Or even better:

template<typename Scalar> class Polynomial : private std::vector<Scalar> {
public:

  template<typename P>
  Polynomial(P&& other) :
    std::vector<Scalar>(std::forward(other)) {}
  ...
};

Conclusions

Generic Programming conciliates:

Design of a generic library based on concepts

The goal of a generic library based on templates is to be as generic as possible (i.e. usable in many contexts) without sacrificing performance. However genericity of some code has its own limits. How to specify to the user these limits, i.e. the requirements a generic algorithm or data structure expects from the types provided by the user in order to work correctly?

Let’s consider our example to determine to what extent function template gcd is generic.

Question: what are the concrete types T that can be passed to the argument Ring?

There are two distinct levels of requirements that types T must satisfy:

  1. Syntactic requirements: the code has to compile
template<typename Ring>
Ring gcd(Ring a, Ring b) {
  Ring e{}, r;
  while(b != e) {
    r = a - b * (a/b);
    a = std::move(b);
    b = std::move(r);
  }
  return a;
}

Therefore, the following expressions must compile:

Expression Syntactic requirement
Ring() Default constructor Ring()
a = arg1 Operator Ring(const Ring&)
a / b Operator Ring /(const Ring&) const
a * b Operator Ring *(const Ring&) const
a - b Operator Ring -(const Ring&) const
a != b Operator bool !=(const Ring&) const
  1. Semantic requirements: the code must correctly run and terminate.

Definitions

Here is an example that illustrates how to use concept to specify the requirements of algorithm gcd:

// Header file containing predefined concepts
#include <concepts>

// Definition of concept Ring
template<typename T> concept Ring =
  std::default_initializable<T> &&  // Reuse here predefined elementary concepts
  std::copy_constructible<T> &&
  std::move_constructible<T> &&
  std::destructible<T> &&
  std::equality_comparable<T> &&
  requires(T a, T b) {                 // Definitions of specific constraints
    { a + b } -> std::convertible_to<T>; // Addition must be defined between two instances of T
    { a - b } -> std::convertible_to<T>; // and return a value implicitely convertible to a value of type T
    { a * b } -> std::convertible_to<T>;
    { a / b } -> std::convertible_to<T>;
  };

// Usage of concept Ring : T must satisfy constraints of concept Ring
template<Ring T>
T gcd(T a, T b) {
  T e{}, r;
  while(b != e) {
    r = a - b * (a/b);
    a = std::move(b);
    b = std::move(r);
  }
  return a;
}

Tips to design a generic library

Exercise

Propose a generic algorithm that computes the weighted barycenter of a set of elements. These elements will be passed to the algorithm using a range of points [begin, end[. The same for weights. One will test the algorithm using provided file test-barycenter.cpp. This programm will check the validity and the generic character of yoru algorithm on elements of type double but also on the class templates Vector and Matrix that you have developped (and that you will complete if necessary). Formalize the requirements of your algorithm by introducing a concept.

Frédéric Pennerath,