Courses/CS 2124/Lab Manual/Smart Pointers

From A-State Computer Science Wiki
Jump to: navigation, search

Introduction

In this exercise the concept of “smart pointers” will be explored. You will develop a simple analog to the C++ unique_ptr, then expand it to be fully functional and generic.

Smart Pointers and RAII

Broadly speaking, we tend to make use of pointers for one of two reasons:

  • To refer to memory that was dynamically allocated; with the responsibility for the lifetime of that item.
    • We refer to these as owning pointers.
  • To refer to an item via indirection, without taking responsibility for the lifetime of that item.
    • We call these non-owning pointers.

Since non-owning pointers are not responsible for maintaining access to a memory resource that must later be deleted, they are relatively trouble-free. Regular C++ pointers (referred to as raw pointers) are fine for this purpose. We will not discuss non-owning pointers further here.

Owning pointers are more problematic. Programmers using raw pointers as owning pointers are running the risk of simply forgetting to delete the resource that the pointer references. There is also the problem of “dropping a pointer” due to flaws in an algorithm. In both cases, the memory resource will “outlive” the pointer that was used to reference it. Both of these problems are logical errors committed by the programmer, not a weakness of the language. Of course, it would be ideal if the language could assist the programmer in the pursuit of error-free code. This is the motivation for smart pointers.

A smart pointer is an object type that wraps an owning raw pointer with the logic necessary to make sure that the resources referenced by the pointer are freed automatically upon the pointer’s demise.

RAII

The RAII design pattern (which stands for “Resource Allocation Is Initialization”) simply states that all resources required by an object should be created during the initialization of that object. When the object is destroyed (by going out of scope or by deletion), the object’s destructor should properly release all of these resources. All of the classes that we have built so far have been examples of the RAII pattern in action. We have learned to allocate necessary resources in the constructor (or perhaps later in a method call), then release all of those resources in the destructor.

Smart pointers are another example of the application of the RAII pattern to owning pointers. Upon creating the smart pointer object, it will either allocate or take ownership of the dynamically-allocated resource. The smart pointer object’s destructor will then take responsibility for deleting that resource when the smart pointer itself is being destroyed.

Smart pointers work by encapsulating the owning pointer as a protected attribute. The smart pointer itself must be designed to behave in the same ways that the underlying raw pointer would behave. This means that the smart pointer must provide overloads for the corresponding operators.

C++ Standard Smart Pointers

Starting with C++11, three types of smart pointer were added to the C++ standard library (in <memory>):

std::unique_ptr : The unique_ptr is used to maintain exclusive (non-shared) ownership of an item. The item is deleted immediately when the unique_ptr is deleted or goes out of scope. See 1.
std::shared_ptr : The shared_ptr is used to maintain shared ownership of an item. This is accomplished by maintaining a count of the number of shared_ptrs referring to the item. When the last copy of the shared_ptr is destroyed, the count drops to zero and the item is deleted. (This approach is referred to as reference counting.) See 2.
std::weak_ptr : The weak_ptr is used in conjunction with shared_ptr when temporary ownership is desired. The presence of a weak_ptr itself does not keep an item from being deleted, but the weak_ptr must be converted to a shared_ptr in order to access the item. See 3.

Unique Pointers

The most sure way to understand how smart pointers work is to implement one ourselves. The simplest form of the smart pointer is a pointer used to maintain exclusive, non-shared ownership. This is analogous to the std::unique_ptr. You will now develop your own unique pointer, called UniquePtr.

The UniquePtr will behave as follows:

  • It may be constructed either by passing a pointer to an object that was already allocated (thus assuming ownership), or by passing initial data that should be used to construct a new instance of the managed object internally.
  • When it is destroyed, the item referenced by the pointer will be freed.
  • It will behave as a pointer, by overloading:
    • dereference (*)
    • member access via pointer (->) (for pointers to objects only)
    • array index operator ([]) (for pointers to arrays only)
  • It will not allow copies to be made (either by assignment or by copy construction)

Building the Smart Pointer Class

Begin by creating the class definition for UniquePtr shown below in a header file named UniquePtr.h. This version of UniquePtr is designed to wrap a pointer to an object of type Foo; you should find the source code defining the Foo class (Foo.h and Foo.cpp) in the Resources section of this assignment.

class UniquePtr{
    public:
        UniquePtr(Foo* ptr);
        ~UniquePtr();
    protected:
        Foo* _ptr;
};

NOTE: Place both the class definition and method implementations in the header file. You need not write all your method definitions directly in the class definition; you can place them below. The reason to keep everything in one header file is that you will be converting your class to a template later in this lab; all template code must reside in a header file.

Constructor
The constructor takes an owning pointer to a Foo object; the UniquePtr will assume ownership of that object, saving the pointer in its _ptr attribute. The expectation is that the UniquePtr will be constructed directly with the result of a new operation, as in the following:

UniquePtr fooPtr{new Foo};

Implement the constructor now.

Destructor
The destructor will destroy the object that is being managed by the smart pointer. Implement the destructor now.

Test
Add code to a driver program that will test your ability to construct and destruct your smart pointer.
Hint: You can use braces to create small sections of scope for the purpose of testing how objects behave when they go out of scope within the context of a running program. See example below:

std::cout << "Creating first object...\n";
UniquePtr p1{new Foo};
{
    std::cout << "Creating second object with short lifetime...\n";
    UniquePtr p2{new Foo};
    std::cout << "Second object is about to go out of scope...\n";
}
std::cout << "Second object should be gone.\n";

This method of creating a smart pointer is the same as what is used for the C++11 unique_ptr type. It is easy to implement, since you are just making a copy of a pointer, leaving the actual memory allocation (new) to the user. However, it is not strictly RAII since the resource is allocated before the object’s initialization takes place.

We can do better. Modify your constructor so that it will actually perform the memory allocation step during construction. Since the Foo class’s constructor takes an optional std::string argument, your new UniquePtr constructor should do the same. This parameter should default to the empty string. Pass the value from the UniquePtr constructor directly into the Foo constructor when you allocate the new object.


Labcheckpoint.png FOR IN-LAB CREDIT: Demonstrate your progress so far.


Overloading Operators

Now that your smart pointer can manage the lifetime of its target object properly, it is time to make it behave like a pointer. Pointers must be able to work with the following operators:

  • Indirection (dereference)
  • Member access via pointer (“arrow”)

The indirection operator is a unary operator that returns a reference to the item whose address is stored in the pointer. In your smart pointer, you simply return (by reference) the result of dereferencing your internal pointer attribute. The prototype is:

Foo& operator* () const;

Implement and test this method now.

The member access via pointer operator (usually called the arrow operator) is also a unary operator. This may be surprising, since it seems that it has an operand on both sides (the pointer on the left and the method on the right). The catch is that the arrow method expands in an unusual way in C++:

MyObjectPtr->someMethod();

Expands to this:

MyObjectPtr.operator->()->someMethod();

So, all your object needs to do is to return a pointer for which the second application of the arrow would be valid. In the case of your smart pointer, you just need to return the wrapped pointer itself; this effectively “passes through” the pointer you are wrapping to the left side of the arrow. Thus, the prototype for your overloaded arrow operator should look like:

Foo* operator->() const;

Implement and test this method now.

Disallowing Copies

Your smart pointer is now able to behave like a raw pointer in the most basic cases. There is one problem though — the UniquePtr class is designed for exclusive ownership of a resource. As such, it should not be possible to make a copy of a UniquePtr. Unfortunately the C++ compiler will provide automatic versions of the copy constructor and assignment operator that do bitwise copies. It is necessary to disallow these operations explicitly.

To disallow an operator, the simplest way is to employ the “deleted method” pattern. By adding = delete; a the end of a method prototype, that method is marked as disallowed, explicitly preventing its use.

Add “deleted method” prototypes for a copy constructor and assignment operator, and note in comments that these operations are being explicitly disallowed. The prototypes are shown below.

UniquePtr (const UniquePtr&)           = delete; // disallow copy construction
UniquePtr& operator=(const UniquePtr&) = delete; // disallow assignment

Now test to make sure that the operations really are disallowed by writing some code that would invoke these operations; verify that the code will not compile, then comment out these lines.


Labcheckpoint.png FOR IN-LAB CREDIT: Demonstrate your progress so far.


Generic Smart Pointer

Your UniquePtr is now complete for the basic set of pointer operations. Others are possible (pointer math for example), but we will not explore those here.

What would be useful is to generalize your UniquePtr so that it can manage resources of any type. You should now convert your smart pointer class into a class template. Be sure to verify that the following items have been considered:

  • The type of the internal pointer attribute is changed to your template type parameter.
  • All references to type Foo are changed to your internal type parameter.
  • In your testing code, all references to the class name UniquePtr must be modified to include the appropriate template argument list (to set the template parameter to Foo).

Make the necessary edits and verify that all of your previous tests still function properly before moving on.

Now, add some testing code that will create and manage smart pointers to objects of type std::string. This should work by default construction and by passing a string to the constructor. Verify that all operations work as expected for managed std::string as well as Foo objects.

Variadic Templates

The fact that your UniquePtr constructor allowed an optional parameter of type std::string was a coincidental benefit when you tested with type std::string (construction in this way works both with Foo and std::string). If you try to manage objects of any other type, your code is likely to fail.

To make your code robust enough to work with all non-default-constructible types, we need some way to pass along any number of arguments, of any type to the constructor of the underlying managed object. C++ allows variadic templates (also referred to as a “parameter pack”) to deal with this kind of situation.

To make use of a variadic template with your constructor, remove your default constructor (if you still have one), and change the constructor that accepts a std::string argument so that it matches the following prototype:

template <typename... Args> UniquePtr(const Args&... args)

The typename... Args template parameter establishes that there may be any number of items provided. This is the variadic part of the template. Then, your parameter list is modified to produce a variadic parameter list. The Args type parameter becomes the type name (passed by const reference), an ellipsis (to indicate variadic parameters), and the formal parameter name args (just an identifier).

Now, to pass this (strange-looking) variadic parameter list on to the constructor of the underlying object, change the actual parameter list in the constructor invocation to:

args...

The whole thing should now look similar to the following (though your code may or may not be inlined):

template <typename... Args>
UniquePtr(const Args&... args) : _ptr{new ValueType{args...}} { }

NOTE: Whitespace and your template parameter name may vary.

Implementation Detail
If you do not implement the constructor shown above inline, you will need to provide a template parameter list for both the class and the method itself as part of the function header. This means there will actually be two separate template parameter lists (which is unusual). This is easiest to understand by looking at an example:

template <typename ValueType> template <typename... Args>
UniquePtr<ValueType>::UniquePtr(const Args&... args) 
: _ptr{new ValueType{args...}} { }

Simply adding a second template parameter to a single list (i.e. template <typename ValueType, typename... Args>) will not work in this situation, since one parameter must refer to the parameterization of the class and the other refers to the parameterization of the method.


Re-run all of your tests to verify that everything still works properly.


Labcheckpoint.png FOR IN-LAB CREDIT: Demonstrate your progress so far.


For those who want to dive a bit deeper (after this lab)

Beginning with C++11, move semantics and perfect forwarding can be used to increase efficiency here. For more information see:


Template Specialization

Thus far, we have overlooked one important aspect of pointers — they may be used to manage an array, not just a single value! This means that we would also like for our smart pointer to be able to manage an array, and to behave as an array when appropriate.

Obviously, this means that we need to define the array index operator (operator[]) for the smart pointer, but it also means that we need to delete the array differently than we delete a single object. This means that the smart pointer must somehow “know” when it is managing an array, and behave differently!

Fortunately, another feature of C++ templates can come to the rescue here: Partial Template Specialization In partial specialization, you write a separate class definition for some possible values of the template argument(s). In this case, we want a different behavior if the template argument is bound to an array type.

Begin this specialization by simply making a copy of your entire class definition and method implementations. Paste the class definition directly below your original class definition, and paste the method implementations below the original implementations. Be sure to mark the transition point with a comment to easily locate the start of each set of methods later.

Now, change your class header from this: (where your whitespace and template parameter name may vary)

template <typename ValueType> class UniquePtr

to something similar to this: (again, your whitespace and template parameter name may vary)

template <typename ValueType> class UniquePtr<ValueType[]>

Notice that here you are specializing the template argument so that it only applies to arrays of the template type.

Next, do the same edit to all of the places in your method implementations where the class name appears on the left of a scope-resolution. For example:

template <typename ValueType>
ValueType& UniquePtr<ValueType>::operator* () const

would change to:

template <typename ValueType>
ValueType& UniquePtr<ValueType[]>::operator* () const

Now, make the following array-specific changes to the code in this class definition:

  • The destructor must call the array delete operator.
  • Remove any non-default constructors (you can’t pass arguments along during an array allocation).
  • Remove the prototype and implementation for the arrow operator (it is of no use with an array).
    • NOTE: The dereference operator is of only marginal use with an array, but it would serve to access the first element, so it will be left intact here.
  • Create a constructor that will accept an argument of type int, representing the size of the array that should be allocated.
    • This constructor should allocate a dynamic array of the template type with the number of elements requested, to be owned by your internal pointer attribute.
  • Create an operator[] method allowing access to an item in the array. The template should look similar to the one shown below.
    • This operator should simply return the element at index i in the managed array. Do not worry about bounds-checking here (for now).
ValueType& operator[](int i) const;

Creating Managed Arrays with UniquePtr
To create a managed array of owned by a UniquePtr, you would do something similar to the following:

UniquePtr<Foo[]>    array_of_Foos{5};     // managed array of 5 `Foo`s.
UniquePtr<double[]> array_of_doubles{10}; // managed array of 10 `double`s.

Where the first example shows construction of a managed array objects, and the second shows a managed array of primitive-type data. Remember that the array containing double values will still begin uninitialized (as usual).

Once the arrays are constructed, you would expect to be able to use them as you would a normal array (or pointer-to-array).

Add tests to your driver code to test the array specialization of your class, and all operations that it supports (focus on those that differ from the non-array version). Verify that no memory is being leaked when the UniquePtr destroys the managed arrays by using the valgrind memcheck tool (or similar).


Labcheckpoint.png FOR IN-LAB CREDIT: Demonstrate your progress so far.


Submit your in-lab files now.