C++ Implementation Idioms

Last update: 2024-12-01 10:50:38 +0800 +0800
Available translations: 日本語 / 中文(简体) / 中文(繁體)

An implementation idiom should make an ownership, lifetime, or dependency boundary explicit. Concise syntax is useful only when the associated invalidation and complexity rules remain visible to the reader.

Erase-Remove for Sequence Containers #

std::remove and std::remove_if are algorithms; they rearrange a range so retained elements occupy a prefix and return the new logical end. They neither erase elements nor change a container’s size. Erase the suffix immediately when the container provides an erase range overload.

cpp
std::vector<int> values{1, 2, 2, 3, 4};
values.erase(std::remove(values.begin(), values.end(), 2), values.end());

values.erase(std::remove_if(values.begin(), values.end(), is_invalid), values.end());

Since C++20, std::erase(values, value) and std::erase_if(values, predicate) express the complete operation for the standard containers that support them. Prefer those overloads when the project’s language and library baseline provides them.

The removal algorithm can move or assign retained elements, so references and iterators to values whose positions change no longer identify the same value. The subsequent erase follows the container’s invalidation rule. For std::vector, erasing invalidates iterators, pointers, and references at or after the erased position; code must reacquire them. Node containers have different rules, so do not transfer vector assumptions to std::list, std::map, or std::unordered_map.

Pimpl Defines a Representation Boundary #

The pointer-to-implementation idiom keeps private representation out of a public header. Clients compile against the stable interface rather than every private member type, so representation changes reduce rebuilds and can preserve an ABI boundary when the rest of the public ABI is also controlled.

cpp
// widget.hpp
class Widget {
public:
    Widget();
    ~Widget();
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;

    Widget(const Widget&);
    Widget& operator=(const Widget&);

private:
    class Implementation;
    std::unique_ptr<Implementation> implementation_;
};

// widget.cpp
class Widget::Implementation {
    // Private state and dependencies.
};

Widget::~Widget() = default;

std::unique_ptr<Implementation> permits the forward declaration in the header, but destruction needs Implementation complete at the point where the deleter is instantiated. Defining the destructor out of line after the Implementation definition supplies that completeness. Copy operations need an explicit deep-copy policy because unique_ptr is noncopyable; omit them when Widget is intentionally move-only.

Pimpl has costs: one allocation in the common form, pointer indirection, less locality, and potentially more complicated debugging. It also does not make every implementation change ABI-safe: inline functions, exception specifications, virtual functions, base classes, and exposed parameter types remain part of the contract.

Dependency Reduction without Pimpl #

Use the lightest boundary that protects the intended contract:

  • Forward-declare a class when the header stores only a pointer or reference to it.
  • Depend on a narrow interface when callers need behavior rather than representation.
  • Keep template definitions and value members in headers only when their compile-time coupling is acceptable.
  • Use Pimpl when a concrete private layout, expensive dependency graph, or ABI commitment justifies runtime indirection.

A type alias can leak representation as effectively as a data member. Returning std::vector<T>::iterator commits clients to the vector’s iterator type; a later container change becomes a source and potentially ABI change.

Indirection Versus Value Semantics #

A direct value member has one allocation domain, clear ownership, and often better cache locality. std::optional<T> represents an in-place value that may be absent without separate allocation. std::unique_ptr<T> represents exclusive ownership of a separately allocated object and is appropriate for polymorphism, incomplete types, large optional state, or distinct identity.

Choose indirection because the object needs its semantic properties—not merely because a member is large. Indirection makes nullability, allocation failure, pointer lifetime, and an additional level of aliasing part of the implementation. Value semantics instead make copying and moving part of the class contract; select them when independent values are what callers need.

Comments

Read or comment on GitHub (sign-in required to comment)