Given a C++ nested private struct type, are there tactics for accessing it from a file scope static function?

Viewed 143

Can someone describe the precise coding tactic alluded to by author John Lakos in the following quote?

John Lakos:

More controversially, it is often better to have two copies of a struct—e.g., one nested/private in the .h file (accessible to inline methods and friends) and the other at file scope in the .cpp file (accessible to file-scope static functions) —and make sure to keep them in sync locally than to pollute the global space with an implementation detail.

The quote appears in the newer Lakos tome, Large Scale C++.

(The book is an updated treatment of the same subject matter as Lakos' earlier book, Large Scale C++ Software Design)

The quote is in Section 0.2, page 9.

If later chapters make it clear what Lakos is describing, I will return here and post an answer.

Meanwhile, I have become fascinated with understanding the quote, and my attempts to scan the book's table of contents and index for further clues have not yet yielded an answer.

Here is my own sample code for the problem that I imagine would be solved by the mysterious tactic:

// THE HEADER

namespace project
{
class OuterComponent
{
public:
    inline int GetFoo()
    {
        return m_inner.foo;
    }

    int GetBar();

private:
    struct InnerComponent
    {
        int foo = 0;
        int bar = 0;
    };

    InnerComponent m_inner;
};
} // namespace project

Together with:

// THE CPP IMPLEMENTATION FILE

namespace project
{
namespace
{
    /*
       MYSTERY:
       Per the book quotation, I can somehow add a "copy of InnerComponent" here?
       And "make sure to keep them in sync locally"?
    */

    // COMPILATION ERROR (see below)
    int FileScopeComputation( OuterComponent::InnerComponent i )
    {
        return i.bar - 3;
    }
} // namespace

int OuterComponent::GetBar()
{
    return FileScopeComputation( m_inner );
}

} // namespace project

The above, of course, will not compile.

The error will be something like:

error: ‘struct project::OuterComponent::InnerComponent’ is private within this context
     int FileScopeComputation( OuterComponent::InnerComponent i )
                                               ^~~~~~~~~~~~~~

The free function named FileScopeComputation cannot access InnerComponent, for reasons I well understand.

Relating the above Code to the Book Quote

Bringing this back to the Lakos quote, my thinking is that FileScopeComputation is one instance of what the quote calls "file-scope static functions".

One "obvious" solution to making the code compile is to move InnerComponent so that it is declared in the public section of OuterComponent.

However, I take my "obvious" solution to be guilty of (per the quotation) "[polluting] the global space with an implementation detail."

Therefore, my code seems to capture both: (a) the goal of the alluded tactic and (b) the unwanted pollution of one potential solution. So what is the alternative solution?

Please Answer Either or Both:

(1) Is there a way to make another copy of struct InnerComponent in the cpp file such that the field OuterComponent::m_inner remains private, the type OuterComponent::InnerComponent also remains private, and yet somehow the function FileScopeComputation has some way of doing something "equivalent" to accessing data on an instance of InnerComponent?

I've tried a couple bizarre things with casting, but nothing that looks even remotely worthy of recommending in a book. Meanwhile, it is my experience that things recommended by Lakos in books are all quite worthy of recommendation.

(2) Am I completely misreading what kind of scenario the quote applies to? If so, then what C++ software design problem is the quote actually referring to? What other problem involves "two copies of a struct... one in the h file.... the other in the cpp file"?

Update:

Based on the perceptive answer by dfri, the above code can indeed be made to compile with minimal changes, and such that:

  • the field OuterComponent::m_inner remains private
  • the type OuterComponent::InnerComponent also remains private
  • and yet the function FileScopeComputation has some way of accessing data on an instance of InnerComponent

The header code gains one extra line of code:

...
private:
    struct InnerUtil; // <-- this line was ADDED. all else is same as above.
    struct InnerComponent
    {
        int foo = 0;
        int bar = 0;
    };

    InnerComponent m_inner;
};

And the cpp file code becomes:

struct OuterComponent::InnerUtil
{
    static int FileScopeComputation( OuterComponent::InnerComponent i )
    {
        return i.bar - 3;
    }
};

int OuterComponent::GetBar()
{
    return InnerUtil::FileScopeComputation( m_inner );
}

1 Answers

The Factoring Pattern (Lakos)

It is possible that Lakos is actually be referring to a separately named type that the public API delegates calls to. There is a feeling of resemblance between the quote and with Lakos Factoring pattern ("the Imp and ImpUtil pattern"), particularly the "ImpUtil" part.

struct A {};
struct B {};
struct C {};

// widgetutil.h
// (definitions placed in widgetutil.cpp)
struct WidgetUtil {
    // "Keep API in sync with Widget::foo".
    static void foo(const A& b, const B& c, const C& a) {
        // All implementation here in the util.
        (void)a; (void)b; (void)c;
    }
    
    // "Keep API in sync with Widget::bar".
    static void bar(const B& b, const C& c) {
        // All implementation here in the util.
        (void)b; (void)c;
    }
};

// widget.h
// includes "widgetutil.h"

// Public-facing API
// (Ignoring the Imp pattern, only using the Util pattern).
struct Widget {
    void foo(const A& a, const B& b) const {
        // Only delegation to the util.
        WidgetUtil::foo(a, b, c_);
    }

    void bar(const B& b) const {
        // Only delegation to the util.
        WidgetUtil::bar(b, c_);
    }

private:
    C c_{};
};

int main() {
    const Widget w;
    w.foo(A{}, B{});  // --> WidgetUtil::foo
}

This is one approach to separates implementation details (WidgetUtil) from the public-facing API (Widget), and also facilitates testing:

  • Implementation details unit tested in isolation in unit tests of WidgetUtil,
  • Testing of Widget can be performed without needing to worry about side-effects in the WidgetUtil members, as the latter can be entirely mocked using static dependency injection in the Widget (making it a class template).

If we look back at the quote from Lakos (in the OP), the WidgetUtil could just as well be placed as a file-scope class in the widget.cpp source file, hidden away from the public API. This would mean even more encapsulation, but would not facilitate testing in the same was as the separation described above.

Finally, note that making Widget a class template does not necessarily mean polluting the translation units of the user(s) of the widget.h with definitions (that need to be compiled in each TU including and instantiating Widget). As the Widget is made a class template solely to facilitate testing, the product implementation of it will ever only use a single instantiation, namely injecting the product WidgetUtil. This means one can separate the definition of the Widget class template member functions from their declarations, just as with non-template classes, and explicitly instantiating the Widget<WidgetUtil> specialization in the widget.cpp. E.g. using the following approach:

  • Separate the definitions of the member functions of the class template Widget from the header file of the class template definition, to a separate -timpl.h header file,
  • The -timpl.h header file in turn is included in the associated .cpp file, which in turn contains an explicit instantiation of the Widget class template for the single type template argument that is used in production, namely WidgetUtil.

Tests could similarly include -timpl.h header and instantiate the Widget class template with a mocked util class instead.

Related