University of Wales Trinity Saint David
ACCB5017 - Games Architecture and Mechanics

Abstraction and Interfaces

Overview

This lab builds a Shape class that declares an interface instead of a concrete implementation, then three classes, Circle, Rectangle and Triangle, that each implement it their own way. Starting from Shape's pure virtual area(), perimeter() and name(), you will write the three concrete shapes and then store all of them in a single std::vector<Shape*>, calling the same method on every element and watching each one run its own version. In the Inheritance lab, a non-virtual start() redeclared in Car hid Vehicle's version rather than replacing it. This lab shows the other half of that picture, a pure virtual method that is correctly dispatched to the derived class every time, no matter what type of pointer is used to call it.

Learning Objectives

By the end of this lab you will be able to:

Concept

A game often needs one function that works across several unrelated types, drawing every shape on screen, updating every entity, saving every item, without a long chain of if statements checking what each one actually is. Adding a new type would mean finding and editing every one of those chains, and forgetting one is easy to miss until it fails at runtime.

C++ solves this with an abstract class. A class becomes abstract the moment it declares a pure virtual function, a method written as virtual returnType name() = 0 with no body at all. The = 0 tells the compiler this class only describes an interface, a set of methods every derived class must supply, and it refuses to let anyone construct the abstract class directly, since there would be nothing to run if one of those methods were ever called. A derived class only stops being abstract once it overrides every pure virtual function it inherited.

A base class can still supply real, shared behaviour alongside its pure virtuals. A plain (non-pure) method written once in the base class can call the pure virtual methods internally, and because those calls are resolved at runtime based on the actual object behind the pointer, the same base class code produces different output for every derived type without ever being duplicated. This only works safely alongside a virtual destructor, virtual ~Shape() {}, which makes sure that deleting a derived object through a Shape* still runs that derived class's own destructor.

Tasks

Task 1 - The Shape interface

Open Shape.h and Shape.cpp and complete the Shape class. It declares the interface every shape must provide but supplies no shape-specific data of its own.

  1. In Shape.h, declare a virtual destructor with an empty body.
  2. Declare three pure virtual methods, area(), perimeter() and name(), each returning the type shown in the skeleton and taking no arguments. Give each one a = 0 body so the class stays abstract.
  3. Declare describe() as a normal (non-pure) const method with no return value.
  4. In Shape.cpp, implement describe() to print the shape's name(), area() and perimeter() to std::cout, in that order, separated by ": area = " and ", perimeter = ".

Success condition: Shape.h and Shape.cpp compile cleanly on their own. If you temporarily add the line Shape shape (with a trailing semicolon) anywhere, the compiler rejects it because Shape is abstract, while a pointer declaration such as Shape* shapePtr compiles without a derived class in sight. You will get a concrete shape to test describe() against in Task 2.

Shape.h
#pragma once

// Shape is an abstract base class. It declares the interface every shape
// must provide but supplies no data of its own, so it can never be
// constructed directly.
class Shape
{
public:
    // TODO: declare a virtual destructor with an empty body

    // TODO: declare area(), perimeter() and name() as pure virtual (= 0),
    // each const, taking no arguments. area() and perimeter() return
    // float, name() returns const char*

    // TODO: declare describe() as a normal const method with no return
    // value (implemented in Shape.cpp, not here)
};
Shape.cpp
#include "Shape.h"
#include <iostream>

void Shape::describe() const
{
    // TODO: print name(), area() and perimeter() to std::cout, formatted as
    // "<name>: area = <area>, perimeter = <perimeter>" followed by std::endl
}

Task 2 - Circle, Rectangle and Triangle: three implementations of one interface

Open Circle.h, Circle.cpp, Rectangle.h and Rectangle.cpp, and complete each class so it implements the Shape interface.

  1. In each header, derive the class publicly from Shape and declare its private state, a radius for Circle, a width and height for Rectangle.
  2. Declare a constructor taking that state as parameters, and declare overrides of area(), perimeter() and name().
  3. In each source file, implement the constructor by assigning every parameter to its matching member using this->.
  4. Implement area() and perimeter() using the standard formulas for that shape, and name() to return the class's own name as a string literal.

Now open Triangle.h and Triangle.cpp and repeat the same pattern, but notice Triangle needs more state than the other two, a base and height for its area, plus sideA and sideB alongside base for its perimeter. Shape does not care how much state a derived class needs. it only requires that area(), perimeter() and name() exist.

Success condition: Circle.h/.cpp, Rectangle.h/.cpp and Triangle.h/.cpp all compile together with Shape.h. Task 3 assembles all three into a program that prints one line of output per shape to confirm each override is correct.

Circle.h
#pragma once
#include "Shape.h"

class Circle // TODO: derive publicly from Shape
{
private:
    // TODO: declare a private float member, radius

public:
    Circle(float radius);

    // TODO: declare overrides of area(), perimeter() and name()
};
Circle.cpp
#include "Circle.h"

const float PI = 3.14159265f;

Circle::Circle(float radius)
{
    // TODO: assign the parameter to the radius member using this->
}

float Circle::area() const
{
    // TODO: return PI * radius * radius
}

float Circle::perimeter() const
{
    // TODO: return 2.0f * PI * radius
}

const char* Circle::name() const
{
    // TODO: return "Circle"
}
Rectangle.h
#pragma once
#include "Shape.h"

class Rectangle // TODO: derive publicly from Shape
{
private:
    // TODO: declare two private float members, width and height

public:
    Rectangle(float width, float height);

    // TODO: declare overrides of area(), perimeter() and name()
};
Rectangle.cpp
#include "Rectangle.h"

Rectangle::Rectangle(float width, float height)
{
    // TODO: assign each parameter to its matching member using this->
}

float Rectangle::area() const
{
    // TODO: return width * height
}

float Rectangle::perimeter() const
{
    // TODO: return 2.0f * (width + height)
}

const char* Rectangle::name() const
{
    // TODO: return "Rectangle"
}
Triangle.h
#pragma once
#include "Shape.h"

// Triangle uses base and height for area, but all three sides for
// perimeter, so it needs more state than Circle or Rectangle to implement
// the same Shape interface.
class Triangle // TODO: derive publicly from Shape
{
private:
    // TODO: declare four private float members: base, height, sideA, sideB

public:
    Triangle(float base, float height, float sideA, float sideB);

    // TODO: declare overrides of area(), perimeter() and name()
};
Triangle.cpp
#include "Triangle.h"

Triangle::Triangle(float base, float height, float sideA, float sideB)
{
    // TODO: assign each parameter to its matching member using this->
}

float Triangle::area() const
{
    // TODO: return 0.5f * base * height
}

float Triangle::perimeter() const
{
    // TODO: return base + sideA + sideB
}

const char* Triangle::name() const
{
    // TODO: return "Triangle"
}

Task 3 - Polymorphic dispatch through a vector

Open AbstractionInterfacesDemo.cpp and complete main() so it exercises all three shapes through the Shape interface alone.

  1. Declare a std::vector<Shape*> named shapes.
  2. Push back a new Circle(3.0f), a new Rectangle(4.0f, 5.0f) and a new Triangle(6.0f, 4.0f, 5.0f, 5.0f), in that order.
  3. Loop over shapes with a range-based for (Shape* shape : shapes) and call shape->describe() on each one.
  4. Loop over shapes a second time and delete each pointer.

Success condition: running the program prints exactly this, with the same numbers for every run:

Circle: area = 28.274, perimeter = 18.8496
Rectangle: area = 20, perimeter = 18
Triangle: area = 12, perimeter = 16

Every line comes from the same call, shape->describe(), through the same Shape* variable. describe() itself is written once in Shape.cpp and never changes, yet its output is different for every element because name(), area() and perimeter() resolve to whichever derived class the pointer actually points to. Deleting through Shape* still runs each derived class's own destructor correctly, because Shape's destructor is virtual.

AbstractionInterfacesDemo.cpp
#include <iostream>
#include <vector>
#include "Shape.h"
#include "Circle.h"
#include "Rectangle.h"
#include "Triangle.h"

int main()
{
    // Shape shape; // error: cannot instantiate abstract class "Shape"

    // TODO: declare a std::vector<Shape*> named shapes

    // TODO: push back a new Circle(3.0f), a new Rectangle(4.0f, 5.0f) and a
    // new Triangle(6.0f, 4.0f, 5.0f, 5.0f), in that order

    // TODO: loop over shapes with a range-based for (Shape* shape : shapes)
    // and call shape->describe() on each one

    // TODO: loop over shapes again and delete each pointer
}

Solution

Try the tasks above before revealing the solution.