A number of other modern, object-oriented programming languages use the keywords struct
and class
but unlike C++ they differ when making copies of objects of these types, possibly by assignment or by use as a function parameter. Simply put, structs (value types) are passes by value, while classes (reference types) are passed by reference. The goal, of course, is efficiency; a reference is cheap to copy.
This article intends to demonstrate that this is also possible with Modern C++, but before that a little (re-)introduction to a long-established coding principle is in order. The “pimpl” idiom (an abbreviation of private implementation) is a way of separating interface from implementation for reasons of code confidentiality and compilation speed. Consider the following class definition:
// BigClass.hpp : minimalist class definition using pimpl-idiom
#include <vector>
#include <string>
#include <string_view>
#include <memory>
class BigClass {
public:
BigClass(const std::string& name, const std::vector<int>& data);
~BigClass();
std::string_view getName() const;
const std::vector<int>& getData() const;
void setAt(const std::vector<int>::size_type index, const int value);
BigClass clone() const;
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
The only data member of this class is pImpl
and all of the member functions, including the constructor, are deliberately merely definitions (not declarations). As it stands, we can #include
this file as it is valid C++, however attempting to compile a program which defines a BigClass
object would result in linker errors. (The declaration of the destructor ~BigClass()
is needed because of using a std::unique_ptr
to an incomplete type.) The type of BigClass::Impl
is a struct
, not a class
, but since it is a private declaration, we do not lose any meaningful encapsulation. We do, of course, need an implementation file, as shown below:
// BigClass.cpp : class implementation for pimpl-idiom
#include "BigClass.hpp"
struct BigClass::Impl {
std::string name;
std::vector<int> data;
};
BigClass::BigClass(const std::string& name, const std::vector<int>& data)
: pImpl{ std::make_unique<Impl>(name, data) }
{}
BigClass::~BigClass() {}
std::string_view BigClass::getName() const { return pImpl->name; }
const std::vector<int>& BigClass::getData() const { return pImpl->data; }
void BigClass::setAt(const std::vector<int>::size_type index, const int value) {
if (index < pImpl->data.size()) {
pImpl->data[index] = value;
}
}
BigClass BigClass::clone() const {
return BigClass(pImpl->name, pImpl->data);
}
The definition of the implementation class BigClass::Impl
is needed in order to write the definitions of the interface class BigClass
in full, as these member functions access the fields directly through the pointer member. The constructor creates the smart-pointer member to a newly created BigClass::Impl
, while the destructor definition is empty. In order to enable deep-copy semantics, a clone()
member function returning a new BigClass
is provided. (It is not possible to copy a BigClass
object directly as the above definition stands as its std::unique_ptr
member is a move-only type.)
What may be surprising is that two simple code changes alter the use-semantics of the above code drastically. Here they are, a change to BigClass.hpp
:
struct Impl;
std::shared_ptr<Impl> pImpl;
};
And a change to BigClass.cpp
:
BigClass::BigClass(const std::string& name, const std::vector<int>& data)
: pImpl{ std::make_shared<Impl>(name, data) }
{}
Changing the smart-pointer type to std::shared_ptr
changes the copy semantics of BigClass
objects to reference semantics. That’s the only change to the code which is necessary! (Additionally, you may even get away without the destructor declaration and definition when changing to std::shared_ptr
.) The following code demonstrates the difference between a shallow copy (using =
which wasn’t possible when using std::unique_ptr
) and a deep copy (using clone()
):
#include "BigClass.hpp"
#include <iostream>
int main() {
BigClass test( "the big data", { 1, 2, 3, 4, 5, 6 });
auto test2 = test.clone();
auto test3 = test;
test.setAt(5, 100);
auto print = [](const BigClass& b){
std::cout << b.getName() << '\n';
for (auto& a : b.getData()) {
std::cout << a << ' ';
}
std::cout << '\n';
};
print(test);
print(test2);
print(test3);
}
The output from running this program is:
the big data
1 2 3 4 5 100
the big data
1 2 3 4 5 6
the big data
1 2 3 4 5 100
In summary, we’ve seen how the pimpl-idiom can be updated to Modern C++ using smart pointers. Modification of the implementation file continues to not require clients of the interface file to be re-compiled. Use of a std::unique_ptr
instead of a raw pointer gives the same semantics to the interface class as ever, while use of a std::shared_ptr
gives the shallow-copy reference semantics seen in other languages.
Resources: Download or browse the source code
Update: 2021/03/07: Code modified to use a nested struct instead of a separate standalone class.
I think you meant to use the word declarations and not the word definitions in at least one paragraph above.
LikeLike
Hi Dan,
I’ve reviewed the article and have actually gone the other way, changing declaration to definition when referring to BigClass.hpp. Examples of declarations are: “struct Impl;” or “~BigClass();” while examples of definitions are: “class BigClass {…};” and “BigClass BigClass::clone() const {…}”
LikeLike