This page looks best with JavaScript enabled

[EMCpp]Item-22 When Using Pimple Idiom, Define Special Member Functions in the Implementation File

 ·  ☕ 4 min read · 👀... views

For std::unique_ptr pImpl pointers, declare special member functions in the class header, but implement them in the implementation file.

The Pimpl Idiom decreases build times by reducing compilation dependeencies between class clients and class implementation. For example:

1
2
3
4
5
6
7
8
9
// header file
class Widget {
public:
    Widget();
    ...
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// impl. file
#include "Widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget ()
: pImpl(std::make_unique<Impl>())
{}

However, even though the code above compiles, client code below won’t compile:

1
2
#include "Widget.h"
Widget w; // error!

The issue arises due to the code that’s generated when w goes out of scope and gets destroyed:

  • Since there’s no user defined destructor, destructor for Widget is generated by compiler, inside which there is a call to the destructor for pImpl
  • pImpl is a std::unique_ptr<Widget::Impl> using default deleter (a function that uses delete on the raw pointer inside the std::unique_ptr)
  • prior to using delete, autogenerated implementation will have the default deleter employ C++11’s static_assert to ensure the raw pointer doesn’t point to an incomplete type
  • here this static_assert fails, because autogenerated destructors are implicitly inline, and thus the definition of Widget::Impl along with its autogenerated destructor inside Widget.cpp hasn’t been seen by compilers

To solve the problem, we need to let the compiler see the body of Widget’s destructor only inside the implementation file after Widget::Impl has been defined:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// header file
class Widget {
public:
    Widget();
    ~Widget();  // declaration only
    ...
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// impl. file
#include "Widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget ()
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget()
{}

To emphasize the fact that the compiler-generated destructor would do the right thing, we can also write like this:

1
Widget::~Widget() = default; 

The same reasoning goes with move operation:

  • the compiler generated move assignment operator requires Impl to be complete because the object pointed to by pImpl needs to be destroyed before assignment
  • the compiler generated move constructor requires Impl to be complete because compilers must be able to generate code to destroy pImpl in the event that an exception arises inside the move constructor (even it the constructor is noexcept)

Once we added the move-related functions, compilers won’t generate copy operations for us. So to support a well-defined deep copy, we need to write our own version.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Widget {
public:
    Widget();
    ~Widget();
    Widget(Widget&& rhs) noexcept;
    Widget& operator=(Widget&& rhs) noexcept;
    Widget(const Widget& rhs);
    Widgt& operator=(const Widget& rhs); 
private:
    struct Impl;
    std::unique_str<Impl> pImpl;
};
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
...
Widget::~Widget() = default;
Widget::Widget(Widget&& rhs) noexcept = default;
Widget& Widget::operator=(Widget&& rhs) noexcept = default;

Widget::Widget(const Widget& rhs)
: pImpl(nullptr)
{ if (rhs.pImpl) pImpl = std::make_unique<Impl>(*rhs.pImpl); }

Widget& Widget::operator=(const Widget& rhs)
{
    if (!rhs.pImpl) pImpl.reset();
    else if (!pImpl) pImpl = std::make_unique<Impl>(*rhs.pImpl);
    else *pImpl = *rhs.pImpl;
    return *this;
}

On the other hand, the above advice does not apply to std::shared_ptr, because in std::shared_ptr, the type of the deleter is not part of the type of the smart pointer, and pointed-to types need not be complete when compiler-generated special functions are employed:

1
2
3
4
5
6
7
8
class Widget {
public:
    Widget();
    ...  // no declaration for dtor or move operations
private:
    struct Impl;
    std::shared_ptr<Impl> pImpl;
};
1
2
3
4
// client code compiles without problem
Widget w1;
auto w2(std::move(w1));  // move-construct w2
w1 = std::move(w2);  // move-assign w1
Share on
Support the author with