Skip to Content
Course content

211: Practice Exercise: Building a Template-Based Generic Container

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first realize you need a container that can hold "anything," your instinct might be to find a way to erase the type. I've seen this a lot with developers coming from languages like Java or C#, or even those trying to write C++ like it's C. They want a single class that can handle integers, strings, or custom User objects without writing the same logic three times. The immediate, naive impulse is to use void* or a common base class.

The Danger of Type Erasure

Imagine we're building a simple FixedArray. The "naive" way to make it generic is to store a pointer to void. You'd allocate a block of memory and just cast whatever the user gives you into that void pointer. It looks flexible on paper, but in practice, it's a nightmare. Since void* has no size or type information, you have to manually track the size of the elements and, more importantly, you have to remember exactly what you put in there to cast it back correctly when you retrieve it.

// The "Don't Do This" Approach
class NaiveContainer {
    void** data;
    int capacity;
public:
    void set(int index, void* value) { data[index] = value; }
    void* get(int index) { return data[index]; }
};

// Usage
NaiveContainer container;
container.set(0, new int(10));
// I hope I remember this was an int, otherwise... boom.
int val = *static_cast<int*>(container.get(0)); 

The problem here isn't just the verbosity of the casts; it's that you've completely bypassed the C++ type system. The compiler is now blind. If you accidentally push a std::string and try to pull it out as an int, the compiler won't say a word. You'll just get a segmentation fault or, even worse, silent memory corruption that takes three days to debug. I've spent far too many weekends chasing bugs born from this exact pattern.

Leveraging Template Instantiation

The better way—the C++ way—is to use templates. Instead of trying to hide the type from the compiler, we tell the compiler to generate a specific version of the class for every type we actually use. When you define a template <typename T>, you aren't writing a class; you're writing a blueprint for a class.

template <typename T>
class GenericContainer {
    T* data;
    int capacity;
public:
    GenericContainer(int size) : capacity(size) {
        data = new T[size];
    }
    ~GenericContainer() { delete[] data; }

    void set(int index, T value) { data[index] = value; }
    T get(int index) { return data[index]; }
};

Now, the compiler handles the heavy lifting. If you declare a GenericContainer<int>, the compiler effectively writes a version of the class where every T is replaced by int. This gives us absolute type safety. If you try to put a string into an integer container, the code won't even compile. You get the error at build time, which is the cheapest possible place to find a bug.

The Cost of Genericity

Now, I should be honest about the trade-offs. Templates aren't free. Because the compiler generates a new class for every unique type you use, you can run into "code bloat." If you use GenericContainer with 50 different types, the compiler generates 50 different versions of those methods in your binary. In most modern applications, this is a negligible cost compared to the massive gain in safety and performance (since we've eliminated the need for runtime casting).

Another quirk you'll notice is that you can't easily split template classes into .h and .cpp files the way you do with regular classes. Since the compiler needs the full definition to instantiate the type, the implementation usually has to live entirely in the header. It feels messy at first, but it's a necessary part of how C++ achieves this kind of zero-overhead abstraction.




📋 Practical Task

Exercise: Implementing a Template-Based Fixed-Size Stack with Bounds Checking

Your goal is to build a generic FixedStack class. This container should allow the user to specify the type of data it holds and a maximum capacity at runtime, but it must ensure that no invalid memory access occurs during push or pop operations.

Requirements:

  • Create a template class FixedStack<T>.
  • Implement a constructor that takes an int capacity and allocates an internal array of type T.
  • Implement a push(T value) method. If the stack is full, it should throw a std::out_of_range exception.
  • Implement a pop() method. If the stack is empty, it should throw a std::out_of_range exception.
  • Implement a peek() method to return the top element without removing it.
  • Ensure the destructor properly cleans up the dynamically allocated array to prevent memory leaks.

Test Case: In your main function, instantiate one FixedStack<int> and one FixedStack<std::string>. Push three elements into the integer stack, pop one, and then intentionally try to push more elements than the capacity allows to verify your exception handling works.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.