CS247 Lecture 13
Last Time: Polymorphic assignment, exceptions This Time: Exception safety, RAII, smart pointers.
We saw in the last lecture, we can throw different type of exceptions from catch blocks. We can also re-throw the same exception to deal with:
Why here do I just say throw
rather than throw e
?
- “throw e” performs a copy in order to throw this exception
- Remember that copy constructors (any type of constructor) cannot be virtual. Therefore, the static type is used for the copy.
If you throw a range_error
and catch via std::exception&
catch block,
throw
re-throwsrange_error
.throw e
catch astd::exception
copied from therange_error
, we lose the dynamic type.
Summary
In the previous catch block, we are catching exceptions of type
std::exception
by reference, which means it can catch expressions ofstd::exception
type and any of its derived classes. If an exception of typerange_erro
(derived fromstd::exception
) is thrown in the try block, it will catch it.throw vs. throw e
- Using
throw
without specifying the exception object, we are essentially re-throwing the currently caught exception. The dynamic type of the exception is preserved, meaning that the original type of the expression that was thrown is still recognized and can be caught by a matching catch block further up the call stack. Allows the program to handle the specific exception type with the appropriate catch block.- Using
throw e
: we are creating a new exception of the same type ase
(which is of typestd::exception
) and throwing that new exception (we don’t have specific type anymore!). While the exception is derived from the basestd::exception
type, the dynamic type information is lost because we constructed a new object. As a result, only thestd::exception
part of the exception is considered, and we lose access to any additional behaviour or information provided by the derived exception type, such asrange_error
. Can lead to incorrect or incomplete error handling.
Generally, catch blocks should catch by reference to avoid copies.
Never let a destructor throw an Exception Handling!
Default behavior: Program immediately craches. That is if an exception is thrown during the destruction of an object and not caught within the destructor itself, the program terminates abruptly. This behavior is known as stack unwinding.
noexcept
is a tag associated with methods (like const), which states the method will never raise an exception.
By default, destructors are implicitly tagged with noexcept
, meaning that they are expected not to throw any exceptions. That is throwing an exception during stack unwinding can lead to multiple exception being active at the same time, making program’s state unpredictable and hard to handle correctly.
We can allow exceptions thrown from destructors by tagging them noexcept (false)
. This can happen when you want to provide more information about an exceptional condition occurring during the destruction process or when you have specific error handle mechanism in place to handle such exceptions.
For example:
Danger
If we throw an exception, stack unwinding occurs, during this process destructors are running for stack allocated objects. If one of these destructors throws, now we have 2 active exceptions!
- 2 active exceptions = program crash (this behavior cannot be changed)
Exception Safety
Under normal circumstances, f
does not leak memory. But, if g
throws, then we do not execute delete p
, so we do leak memory!
Thing to recognize: exceptions significantly change control flow! We no longer have the guarantee fo sequential execution.
Let’s fix f
, so we can handle its memory leak.
Complaints about the fix above:
- Repeated code.
delete p
twice, a little annoying. - Could get complicated for many pointers, many function calls, etc.
In other languages, some have “finally” clause, which always runs, either after a successful try, a successful catch, or before a catch returns. Unfortunately, C++ doesn’t have support for this.
- The only guarantee is that during stack unwinding, stack allocated objects have their destructors run.
- Therefore, use stack allocated objects instead no leak
What if we need a pointer? For example, to achieve Polymorphism
One solution: wrap the pointer in a stack allocated object that will delete it for us during stack unwinding. ???????? How do I do that??????? (Smart pointers)
C++ Idiom: RAII (resource acquisition is initialization)
Example:
Resource (file handler) is acquired in the constructor (initialization). Resource is freed (closing the file) in the destructor.
Apply this RAII concept to dynamic memory. std::unique_ptr<T>
(in <memory>
library)
- contains a
T*
passed via the constructor - deletes the
T*
in the destructor
If we leave f
normally, or if we leave f
during stack unwinding, either way, p
’s destructor runs, deletes heap memory (because of implement of unique_ptr
).
In between, we can use p
like a regular pointer thanks to operator overload.
????? Why thanks to operator overload??????
A: It’s just that we can use p
as a regular pointer thanks to the fact that std::unique_ptr
overloads operators such as ->
and *
to mimic pointer behaviour.
Generally, we don not call the constructor directly with a pointer, because of the following issues:
std::unique_ptr<T> p{newT{}};
new is not paired with a delete (not one that we see, at least) If an exception is thrown in this scenario after thenew
but before thestd::unique_ptr
is constructed, the dynamically allocated memory might not get properly deallocated, leading to memory leak. We can avoid this by directly initializing thestd::unique_ptr
with anew
-allocated object. (Initialize astd::unique_ptr
usingstd::make_unique
):
- Causes a double delete:
If we use raw pointer to create an object and then use that pointer to initialize more than one std::unique_ptr
, we’ll end up with a double deletion problem because each std::unique_ptr
will try to delete the same memory. So it is unsafe.
g()
can potentially throw in the example below, heap-allocated objects does not get deleted
If g()
throws an exception, the std::unique_ptr
created with new T()
would be lost, and the memory allocated by new
would not be properly deallocated, leading to memory leak. This can be avoided by using std::make_unique
or manually handling exceptions and memory cleanup.
This is because of the order of operations and exception handling. If g()
throws an exception:
- If
g()
throws an exception before the temporarystd::unique_ptr<T>
object is constructed, then no memory leak will occur because the memory allocated bynew T()
will not be lost; it has never been assigned to astd::unique_ptr
yet. - If
g()
throws an exception after the temporarystd::unique_ptr<T>
object is constructed but beforef
is called, then thestd::unique_ptr<T>
object will go out of scope and properly delete the dynamically allocated memory (because its destructor will be called during the stack unwinding caused by the exception). - However, if
g()
throws an exception after thef
function is called and the temporarystd::unique_ptr<T>
object is passed tof
, then the dynamically allocated memory will be leaked because the exception prevents proper cleanup. thestd::unique_ptr<T>
object’s destructor will not be called because the exception unwinds the stack. The temporarystd::unique_ptr<T>
object’s destructor will not be called automatically because it goes out of scope when the full expression is evaluated (afterg()
is called), not at the exact point whereg()
throws. Since we throw wheng()
is called, we will never get to finish and call the destructor.
Summary
If we use
new
to allocate memory separately and then assign the raw pointer to astd::unique_ptr
, you could encounter memory leaks or double-deletion issues if the code is not designed carefully. Directly initializing astd::unique_ptr
using its constructor or usingstd::make_unique
is recommended
One potential ordering (in C++14) (obscure scenario)
new T()
g()
unique_ptr constructor
f()
Preferred alternative: std::make_unique<T>
(…)
- Constructs a
T
object in the heap with arguments …, and returns aunique_ptr<T>
pointing at this object.
Something else to consider:
Call copy constructor to copy p
into q
.
What happens? Doesn’t compile. Copying is disabled for unique_ptrs
. They can only be moved. (Compile time error if it does happen)
This is achieved by setting copy constructor / assignment operator to = delete
.
C++ ensures that we cannot copy std::unique_ptr
instances by implementing = delete
(which is a trick we can also use). Only able to move them, allowing proper ownership transfer instead.
unique_ptr
are good for representing ownership, since when one object dies, its unique_ptr
fields will run and clean up the associated object.
Has-a relationship: Use raw pointers or references.
- You can access the underlying raw pointer of a smart pointer via
.get()
.
Note
The
.get()
method is a member function provided by C++ smart pointer classes, such asstd::unique_ptr
,std::shared_ptr
, etc. This method allows you to retrieve the raw pointer that the smart pointer is managing. In other words,.get()
returns the actual pointer value stored by the smart pointer.
Next: CS247 Lecture 14