Skip to content

"Smart Pointers": Why Are You Using Them?

amirroth edited this page Apr 8, 2023 · 2 revisions

Here's something that sounds fun and fancy ... a "smart pointer". If you are going to return a pointer to something, you may as well make it "smart" whatever that means. Well, what does it mean? What is "smart" about a "smart pointer"?

Program data memory is divided into three logical regions:

  • The "stack" which holds parameters and local variables of all active functions. The stack is managed automatically, there is nothing special you have to do here. In a multi-threaded program, each thread gets its own stack so that the threads can do function calls independently with no interference.
  • The "global" or "static" area which contains all global variables. Again, this area is also managed automatically. In a multi-threaded program, all threads share a single "static" data area. EnergyPlus does not use global data anymore.
  • The "heap" which is managed dynamically and holds objects that are allocated and destroyed at runtime. In a multi-threaded program, all threads share a single "heap".

In C and C++, the heap is explicitly managed. In C, the standard library functions malloc and free allocate and free heap memory respectively. In C++, these functions are wrapped in the new and delete operators which also call the constructor and destructor of the object. These functions are also often hidden in templates like std::vector (there is malloc/new in the constructor and in push_back and free/delete in the destructor) and ObjexxFCL::Array1D (there is malloc/new in .allocate() and .dimension() and free/delete in .clear() and in the destructor).

One type of bug that is commonly associated with explicit memory management is the "memory leak", i.e., forgetting to free a piece of memory once you are done with it. If you do this repeatedly, eventually you will run out of virtual memory and malloc or new or .push_back or .allocate() will fail. This bug is sufficiently pervasive and insidious that dynamic languages like Python, in which all objects (and even individual members of objects!) live on the heap and which requires intensive heap management, don't actually require the programmer to free allocated memory explicitly. Instead, their runtime systems implement "garbage collection", which is a procedure that discovers which allocated objects can no longer be used--an object can no longer be used if all of the pointers to it have gone out of scope--and reclaims the memory automatically.

Garbage collection sounds really cool and handy and so the C++ library includes features that allow you to add that functionality to selected objects via "smart pointers". There are several flavor of smart pointer, the two most common are:

  • std::unique_ptr will cause the object to be freed when the pointer that first created it goes out of scope. That initial pointer essentially "owns" the object.
  • std::shared_ptr will cause the object to be freed when all pointers to that object have gone out of scope.

Easy enough. The obvious next question is, when should you use which? Well, in my opinion, EnergyPlus memory management is such that you never need to use shared_ptr. There may be benefits to using unique_ptr but I am somewhat fuzzy on what they are (they have to do with exceptions and move constructors and returning objects from functions) and, again, EnergyPlus memory management is such that I don't think they are necessary.

What does "EnergyPlus memory management is such" mean? EnergyPlus is not a typical scientific program in many respects, but one respect in which it is typical is that it has "runtime static" memory management, the exact amount and configuration of heap memory required is not known at compile-time (if it were all that memory could be just laid out implicitly in the "static" segment) but it is known shortly thereafter (in the case of EnergyPlus from the IDF file) and the program essentially proceeds in three phases:

  • It creates all the objects it will need during the computation.
  • It computes on those objects.
  • It disposes of those objects (or not) and exits.

In EnergyPlus, there is no meaningful opportunity for a memory leak. You are not going to get to the middle of a simulation and find out that you need additional memory that you cannot get. You know exactly how much memory you are going to need at the outset.

In addition, there is no real notion of "object lifetime". All EnergyPlus objects have essentially the same lifetime, from the beginning of the simulation until the end.

Finally, in EnergyPlus there is no real notion of one object "owning" another and therefore being responsible for its deallocation. All objects are allocated into arrays. They are then referenced out of those arrays either by index, pointer, or reference, but it's that initial array that "owns" the objects. An object should be freed when the array that contains it (or the pointer to it) is freed. That rule essentially encompasses all cases in EnergyPlus.

Taken together, these mean that there is little benefit to shared_ptr and unique_ptr. We know up-front what the owning pointer of every object is, the pointer in the array that contains that type of object, and we know that it will also be the last pointer to go out of scope. We also know exactly when that object needs to be freed, before the containing array is deallocated in clear_state(). Instead of doing this:

GroundHeatExchangerData : public BaseGlobalStruct {
    ...
    std::vector<std::shared_ptr<GroundHeatExchangers::GLHEVertSingle>> singleBoreholesVector;
    ...

    void clear_state() {
       ...
       singleBoreholesVector.clear();
       ...
    }

and having to take the overhead of shared_ptr everywhere (which is admittedly not huge, but it is something since a shared_ptr is a larger object that contains more information that just a pointer). You can do this instead with no overhead:

GroundHeatExchangerData : public BaseGlobalStruct {
    ...
    std::vector<GroundHeatExchangers::GLHEVertSingle*> singleBoreholesVector;
    ...

    void clear_state() {
       ...
       // delete/deallocate all pointed-to objects
       for (int i = 0; i < singleBoreholesVector.size(); ++i) delete singleBoreholesVector[i]; 
       singleBoreholesVector.clear();
       ...
    }

This is the only pattern you need to use. At this point, this is the only memory allocation/deallocation pattern that EnergyPlus uses.

Clone this wiki locally