CS 70

Making CoordVector Dynamic

Your maze solver works great on small mazes, but it crashes on larger ones because CoordVector can only hold 256 coordinates. Time to fix that! We'll transform our fixed-size vector into a dynamically sized one that can grow as needed.

  • Pig speaking

    Can't we just make the array BIGGER? Like 10,000 elements?!! Or maybe MORE?!!!

  • LHS Cow speaking

    We could, but that wastes memory if we're working with small mazes and still might not be enough for huge mazes. Dynamic sizing gives us the best of both worlds.

  • Hedgehog speaking

    That sounds complicated.

  • LHS Cow speaking

    We'll take it step-by-step. By the end, you'll have built something very similar to how std::vector actually works!

The Plan: Three Careful Steps

Rather than trying to do everything at once (a recipe for disaster), we'll proceed in three stages:

  1. Dynamic allocation: Replace the fixed array with a heap-allocated array.
  2. Add infrastructure: Implement swap() and track capacity.
  3. Dynamic resizing: Automatically grow the array when needed.

Each step will compile and pass tests before moving to the next. This way, if something breaks, you know exactly where the problem is.

Your Task, Step 1: From Stack Frame to Heap

First, let's replace our fixed-size array (which typically lived inside a function's stack frame) with a dynamically allocated one. The size will still be fixed for now, but it will live on the heap.

Update the Header File, Phase 1: Eliminate MAX_SIZE

First, in the header coordvector.hpp, replace

static constexpr size_t MAX_SIZE = 256;   // Arbitrary fixed maximum size

with

size_t capacity_ = 256;                   // Capacity of the array

and also do a search-and-replace to change all occurrences of MAX_SIZE to capacity_ in the implementation file (coordvector.cpp). With these small changes, everything should still build and work exactly as before, but now the capacity is stored in a member variable instead of a static constant.

Update the Header File, Phase 2: Change the data_ Data Member

Now for the big change. We're going to replace the fixed-size array with a pointer to a dynamically allocated array. Remember that array operations rely on a pointer to the first element of the array, so we can use a pointer to that first element (on the heap) instead of a fixed array.

So we'll change

Coord data_[MAX_SIZE];                // The underlying array

to

Coord* data_ = nullptr;               // Pointer to dynamically allocated array
  • RHS Cow speaking

    For now we're just changing the declaration. We still need to allocate the array in the constructor and deallocate it in the destructor, which we'll do shortly. Our code might still compile, but it certainly won't work yet!

Update the Header File, Phase 3: Add a Destructor

One rule with the heap is that every new must be matched with a delete to avoid memory leaks. So we'll need a destructor to clean up the array when the CoordVector is destroyed. So add a destructor declaration in the public section (adding appropriate documentation):

~CoordVector();
  • LHS Cow speaking

    If we tried to compile now, we'd get a linker error because the destructor is declared but not defined. But we're implementing the destructor in coordvector.cpp next, so hang tight!

Update the Implementation, Phase 1: Revise the Constructors

Our delegating default constructor can stay the same, but we need to update the parameterized constructor to allocate the array on the heap. So in coordvector.cpp, update the parameterized constructor to

CoordVector::CoordVector(size_t initialSize)
    : capacity_{XXXCAPACITYXXX},
      data_{XXXPOINTERXXX},
      size_{initialSize} {
    if (initialSize > capacity_) {
        throw std::overflow_error("Initial size exceeds capacity");
    }
}

This code has placeholders XXXCAPACITYXXX and XXXPOINTERXXX that you'll need to replace with the appropriate expressions.

For XXXCAPACITYXXX, let's just start with our existing fixed capacity of 256. We'll hard code it as a magic number for now because it's just a placeholder that we'll change soon. You can add it as 256 /* FIXME */ to remind yourself to change it later if you like.

For XXXPOINTERXXX, we need to allocate a new array of Coord objects on the heap. The syntax for allocating an array on the heap is new TypeName[arraySize], which will return a pointer to the first element of the array (thus it returns a TypeName*). For example, new int[10] allocates an array of ten integers on the heap and returns an int* pointing to the first element.

We want to allocate an array of what type of objects?

What size should the array be?

And based on those answers, what expression should we use to replace XXXPOINTERXXX?

Update the Implementation, Phase 2: Implement the Destructor

Now we need to implement the destructor to clean up the array when the CoordVector is destroyed. The syntax for deallocating an array allocated with new is delete[] pointer, where pointer is a pointer to the first element of the array.

The necessary pointer value should be readily at hand in a member variable. Which one?

So the full delete[] statement should be (including both delete and the semicolon):

  • RHS Cow speaking

    If you like, you can also set data_ to nullptr after deleting it. It's not strictly necessary since the object is being destroyed anyway, but this kind of defensive programming can help if something else goes horribly wrong elsewhere. We don't want to leave any values in memory that look “plausible” but are actually invalid.

Sanity Check

Whenever you write code using new and delete, it's good to go through this checklist:

  • For every call to new, do you know where and when the corresponding delete will happen?
  • For everything allocated with new, are you using the right kind of delete? (I.e., if you used square brackets [] to allocate an array, you must use delete[] to deallocate it; if you didn't use square brackets, you must use plain delete.)
  • Are you sure about who owns the allocated memory? (Only the owner is responsible for cleaning it up.)

The answers should be straightforward here, but it's good practice to get into the habit of asking yourself these questions.

Test Step 1

Compile and run the tests:

make coordvector-test
./coordvector-test

Everything should still work exactly as before. If not, debug before continuing!

  • Hedgehog speaking

    Wait—we're still limited to 256 elements?

  • LHS Cow speaking

    Yes, for now. We're taking baby steps. First, we make sure the heap allocation works, then we'll make it grow.

Your Task, Step 2: Adding Useful Features

One thing that's cool about our switch to having a pointer to a heap-allocated array is that it now becomes incredibly efficient to swap two CoordVector objects. Previously, if we wanted to swap two CoordVectors, we'd have to swap all 256 elements between the two arrays one-by-one. Now we can just swap the pointers and sizes, which is a constant-time operation.

We'll also let users query the current capacity of the vector. Both of these features are present in std::vector, so it's good practice to implement them here, and they'll turn out to be very useful in our later code.

Edit the Header

In the public section of coordvector.hpp, add (with appropriate documentation):

void swap(CoordVector& other);
size_t capacity() const;

Implement the New Functions

At the top of coordvector.cpp, add

#include <utility>  // for std::swap at the top

so that we can use std::swap. This is a very handy utility function that swaps two values of any built-in type, such as integers, pointers, and so on.

Your implementation of swap() should just go through each data member and swap it with the corresponding data member of other. For example, to swap the size_ members, you can write std::swap(size_, other.size_);. Do the same for all three data members, data_, size_, and capacity_.

The capacity() function is straightforward: it just returns the capacity_ member variable.

Update the Test File

In coordvector-test.cpp, we've used the preprocessor to “comment out” the test_swap() function. To enable it, just remove the #if 0 and #endif lines surrounding it. Then uncomment the call to test_swap() in main() as well.

Test Step 2

Rebuild and test:

make coordvector-test
./coordvector-test

The swap test should now pass!

Your Task, Step 3: Dynamic Resizing

Now for the exciting part: making the vector grow automatically! The heart of our dynamic resizing will be a new function called reserve(), which will let us expand the capacity of the vector—when you call reserve(new_capacity), if the capacity is already at least new_capacity, nothing happens; otherwise, we allocate a new array with the requested capacity, copy over the existing elements, and deallocate the old array.

To help with that copying process, we have a special private constructor that takes another CoordVector and a new capacity, and creates a new CoordVector with the specified capacity, copying over the elements from the other vector.

Update the Header to Add the Private Copying Constructor and reserve()

First, add this function to the public section of coordvector.hpp (don't forget the documentation):

void reserve(size_t new_capacity);

And add this special private constructor to the private section of coordvector.hpp:

// Helper copy constructor with specified capacity
CoordVector(const CoordVector& other, size_t new_capacity);

Implement the Private Copying Constructor

You need to implement the private copying constructor in coordvector.cpp. It should create a new CoordVector with the specified capacity, copy over the elements from other (throwing an exception if new_capacity is less than other.size_).

Here are some tips for implementing it:

  • Although you can do it directly by hand, it's easier to delegate to the public constructor that takes an initial size. You can do this by using a member-initialization list that calls CoordVector{other.size_}. This will create a default-initialized array of the right capacity and size.
  • After delegating to that constructor, you can then copy over the elements from other using a simple loop. Except…

Remember what we said about using logicalToPhysical()!

It's super tempting to write the loop as

    // Do NOT do this!
    for (size_t i = 0; i < other.size_; ++i) {
        data_[i] = other.data_[i];
    }

But earlier on we warned you to always use logicalToPhysical() to convert logical indices to physical indices. One way to do that is to just write other_[i] instead of other.data_[i] and use your operator[] (which in turn will use logicalToPhysical()). Or you can just call logicalToPhysical(i) on both sides of the assignment. Either way is fine, as long as you remember to do it!

  • Horse speaking

    Hay! You told us how to use our own operator[] for other, but what about the replacement code for data_[i] on the left side?

  • LHS Cow speaking

    It's the same idea, but uglier. You can use (*this)[i] to access the current object's elements using your own indexing code.

  • Duck speaking

    Wow, that really is ugly. I know that this means the object we're working on, but why is it a pointer to the object? Surely it should be a reference to the object? That would make so much more sense!

  • Bjarne speaking

    It is *ahem* part of our heritage. Much like we're evolving an implementation of CoordVector, C++ evolved from C. Initially, C++ didn't have references at all, so this had to be a pointer. Later, when I enhanced C++ by adding references, it was too late to change this to be a reference, so it stayed a pointer.

  • LHS Cow speaking

    And now everyone lives with it. Whee.

  • RHS Cow speaking

    If it really bugs you, you can always write CoordVector& self = *this; at the top of the function and then you can just write self[i].

Implement reserve()

The job of the reserve() function is to ensure that the vector has at least the specified capacity. If the current capacity is already at least new_capacity, it does nothing. Otherwise, it needs to allocate a new array with the requested capacity, copy over the existing elements, and deallocate the old array.

We can do this task easily using the functionality we've already built. Here's the pseudocode:

if new_capacity < size_:
    throw an exception  // we can't shrink the vector
else if new_capacity  capacity_:
    return              // having done nothing
else:
    create a new temporary CoordVector using our private copying constructor, passing *this and new_capacity
    swap *this with the new temporary vector
  • Duck speaking

    Wait, what? How are we going to make sure the old array gets deleted?

  • LHS Cow speaking

    That's the beauty of this approach. When we create the temporary CoordVector, it allocates a new array and copies over the elements. Then we swap our current object with the temporary one, so now our current object has the new array and the temporary object has the old array. When the temporary object goes out of scope at the end of the function, its destructor is called, which deletes the old array. So everything gets cleaned up automatically!

  • RHS Cow speaking

    This pattern is called “copy and swap” and it's a fundamental C++ idiom for exception-safe resource management. We also mentioned it in this week's lesson.

Update Constructor and pushBack

Update the parameterized constructor to start with a reasonable capacity. Replace the placeholder 256 /* FIXME */ with std::max(initialSize, size_t{1}) or similar code that achieves the same effect. The rule is

if initialSize is 0:
    capacity_ is initialized to 1    // we never want a zero-capacity vector
else:
    capacity_ is initialized to initialSize

(But we can't write an if statement in a member-initialization list, so we need to use an expression that achieves the same effect.)

Using std::max() is probably the most readable approach, but if you choose that option, you'll need to add #include <algorithm> at the top of coordvector.cpp. There are other equally valid and widely-used approaches, including using condition ? true-case : false-case (a.k.a. the ternary operator) to directly replicate the above conditional code, or you can use a simple helper function. But you must do the computation inside the member-initialization list, not in the body of the constructor, because the initialization of data_ depends on capacity_ having the right value.

In the code for pushBack(), replace the line that threw an exception when the vector was full with a call to reserve() to double the capacity instead. That should be the only change you need to make to pushBack(), as the code path after the if continues on and adds the new element.

Add a Public Copy Constructor

Given that we already have a private copying constructor, it's super easy to implement a public copy constructor. Amend the header file to remove the = delete from the copy-constructor declaration, and then implement it in coordvector.cpp by delegating to the private copying constructor with the same size as the other vector. That's it.

Final Testing

Uncomment test_realloc() in the test file (i.e., delete both the #if 0#endif lines and uncomment the lines in main) and run all tests:

make coordvector-test
./coordvector-test

The result should look like

CoordVector basics passed!
CoordVector swap passed!
CoordVector pushBack loop passed!
CoordVector reallocation passed!

All tests passed! Summary of affirmations:
----
Fails   / Total Issue
0       / 24    [CoordVector basics]
0       / 5     [CoordVector swap]
0       / 602   [CoordVector pushBack loop]
0       / 801   [CoordVector reallocation]

Now try the big maze:

make
./amaze mazes/maze-91x43.txt

If all is well, it'll work! Your vector now grows dynamically as needed.

  • Goat speaking

    Meh. Why double the capacity? Why not just add 10 or something?

  • LHS Cow speaking

    The short version is that doubling is the most efficient way to balance memory usage and performance. We'll take a deeper dive into why it's the best strategy in a future lesson—and there's an option to think about it in Part 9.

Helpful Hints

Tracking Down Crashes and Memory Leaks

If your code crashes, running it with valgrind will give you a backtrace that shows you where the crash happened. Just run:

valgrind ./coordvector-test

Running your code this way will also detect some other kinds of errors, such as accessing memory you shouldn't be accessing. If you see messages about “invalid read” or “invalid write”, that's a clue that you're accessing memory you shouldn't be accessing.

You can also use valgrind to check for memory leaks. For maximum effectiveness, run it like this:

valgrind --leak-check=full ./coordvector-test

If there are no other errors and you see “no leaks are possible”, you're golden!

Common Mistakes

  • Forgetting to delete[] (with brackets!) in the destructor.
  • Using delete instead of delete[] for arrays.
  • Not updating capacity_ when resizing.
  • Forgetting to copy the data in the copy constructor.

Understanding Capacity vs. Size

  • Size: How many elements are currently in the vector
  • Capacity: How many elements we can hold before needing to resize
  • Size ≤ Capacity always!

To Complete This Part of the Assignment…

You'll know you're done with this part when you've done all of the following:

(When logged in, completion status appears here.)