Thread: Patterns that avoid two-phase initialization and exceptions

  1. #1
    Kiss the monkey. CodeMonkey's Avatar
    Join Date
    Sep 2001
    Posts
    937

    Patterns that avoid two-phase initialization and exceptions

    I'm prototyping a new library for distributed tracing. It will solve the same problems that GitHub - DataDog/dd-opentracing-cpp: Datadog Opentracing C++ Client solves.

    It'll require C++17, and the error reporting mechanism I'd like to use is to return std::variant<T, Error> by value. I'm not going to bother to implement something like the proposed std::expected. For example,

    Code:
    #include "error.h"
    #include "span.h"
    
    class DictReader;
    
    class Tracer {
        // ...
      public:
        std::variant<Span, Error> extract_span(const DictReader&);
    
        // ...
    };
    extract_span returns a value that's either a Span (the happy path), or an Error (the failure path).

    This works for "operations" (e.g. methods, free functions), but it does not work for constructing new objects.

    Each object in the domain is configured by a dedicated configuration object, e.g.

    Code:
    struct TraceSamplerConfig { /*...*/ };
    
    class TraceSampler {
        // ...
      public:
        explicit TraceSampler(const TraceSamplerConfig&);
    
        // ...
    };
    The TraceSamplerConfig might be invalid. Or, configuration overrides specified as environment variables might fail to parse correctly.

    C++ has a solution for this: exceptions. Creating a TraceSampler might throw an exception, which means that you don't have a TraceSampler after all.

    My colleague dislikes use of exceptions except in rare situations (e.g. recursive descent parsers). He thinks that in an API they are too easy to misuse.

    Fair enough. I'm not 100% convinced, but let's look at other options.

    We could have a separate "configure" method. But now the object has a state before configuration and a state after configuration. All operations on the object have to consider both states.

    Code:
    #include "error.h"
    
    struct TraceSamplerConfig { /*...*/ };
    
    class TraceSampler {
        // ...
      public:
        TraceSampler();
    
        std::optional<Error> configure(const TraceSamplerConfig&);
    
        // ...
    };
    This too I would like to avoid.

    What else?

    We could avoid constructors entirely and instead provide factory functions, e.g.

    Code:
    #include "error.h"
    
    struct TraceSamplerConfig { /*...*/ };
    
    class TraceSampler {
        // ...
        TraceSampler();
        friend std::variant<TraceSampler, Error> make_trace_sampler(const TraceSamplerConfig&);
    
      public:
        // ...
    };
    
    std::variant<TraceSampler, Error> make_trace_sampler(const TraceSamplerConfig&);
    Factory functions are more common when the object (e.g. TraceSampler) is an interface. Then the factory would return a shared_ptr to the object rather than the object itself, but that difference doesn't matter here.

    My colleague also dislikes this arrangement. One thing that I don't like about it is that it seems to skirt around constructors, a perfectly reasonable part of the language. Why? Because we wanted to avoid exceptions and two-phase initialization.

    The best idea I've come up with so far is the following, but it has some issues. Rather than get in the way of constructing the object, instead require that the configuration used is pre-validated.

    Code:
    #include "error.h"
    
    struct TraceSamplerConfig { /*...*/ };
    
    template <typename Config>
    class Validated { /* ... */ };
    
    class TraceSampler {
        // ...
    
      public:
        explicit TraceSampler(const Validated<TraceSamplerConfig>&);
    };
    
    std::variant<Validated<TraceSamplerConfig>, Error> validate(const TraceSamplerConfig&);
    Now TraceSampler's constructor cannot fail, but the user must first produce a Validated<TraceSamplerConfig> using the "validate" function, which may fail.

    Seems a bit roundabout, but I like it. There is yet a problem, though.

    These domain object (Tracer, TraceSampler, Span, etc.) may contain each other as members. Thus a TracerConfig object might contain a TraceSamplerConfig object.

    Should a Validated<TracerConfig> object contain a Validated<TraceSamplerConfig> object or a TraceSamplerConfig object?

    If it contains a Validated<TraceSamplerConfig> object, then we can use the Validated<TraceSamplerConfig> to construct a TraceSampler, but the problem is that now Validated<T> is not just a wrapper around T. I'd need two manually maintained configuration types for each object: one validated and the other unvalidated.

    If instead it contains a TraceSamplerConfig object, then we cannot use the TraceSamplerConfig object to construct a TraceSampler, because TraceSampler requires a Validated<TraceSamplerConfig>.

    I don't know whether this has exposed a fundamental problem with the "validated wrapper" idea, or if there's a better way.

    What do you think?
    "If you tell the truth, you don't have to remember anything"
    -Mark Twain

  2. #2
    Kiss the monkey. CodeMonkey's Avatar
    Join Date
    Sep 2001
    Posts
    937
    I have a solution, but I don't know how much I like it. It's working, anyway.

    The trick is to define a function, "bless," that given a Validated<Foo> and a pointer-to-Bar-member-of-Foo, will yield a Validated<Bar>.

    Code:
    template <typename Config>
    class Validated : public Config {
        template <typename Child, typename Parent>
        friend Validated<Child> bless(Child::*member, const Validated<Parent>& parent);
    
        explicit Validated(const Config&);
    };
    
    template <typename Child, typename Parent>
    Validated<Child> bless(Child::*member, const Validated<Parent>& parent) {
      return Validated<Child>(parent.*member);
    }
    This way, if TracerConfig contains a TraceSamplerConfig, then given a Validated<TracerConfig> we can now produce a Validated<TraceSamplerConfig>.

    Code:
    struct TraceSamplerConfig { /* ... */ };
    
    struct TracerConfig {
        // ...
        TraceSamplerConfig trace_sampler;
    };
    
    void example(const Validated<TracerConfig>& config) {
        Validated<TraceSamplerConfig> sampler_config =
            bless(&TracerConfig::trace_sampler, config);
        // ...
    }
    I can then add overloads of bless for std::optional<Child> members, etc.

    Worth the trouble?
    "If you tell the truth, you don't have to remember anything"
    -Mark Twain

  3. #3
    C++ Witch laserlight's Avatar
    Join Date
    Oct 2003
    Location
    Singapore
    Posts
    28,413
    Quote Originally Posted by CodeMonkey
    My colleague dislikes use of exceptions except in rare situations (e.g. recursive descent parsers). He thinks that in an API they are too easy to misuse.
    I'm a bit amused because one of the rhetorical questions Stroustrup asks in his answer to the FAQ Why use exceptions? is "Imagine that we did not have exceptions, how would you deal with an error detected in a constructor?"

    Rather than just saying "fair enough", I'd explore the details of this objection to see if it stands up to scrutiny. How are they too easy to misuse? Is this aforementioned misuse by the API authors? By the API users? Is this particular case a case of misuse?
    Quote Originally Posted by Bjarne Stroustrup (2000-10-14)
    I get maybe two dozen requests for help with some sort of programming or design problem every day. Most have more sense than to send me hundreds of lines of code. If they do, I ask them to find the smallest example that exhibits the problem and send me that. Mostly, they then find the error themselves. "Finding the smallest program that demonstrates the error" is a powerful debugging tool.
    Look up a C++ Reference and learn How To Ask Questions The Smart Way

  4. #4
    Kiss the monkey. CodeMonkey's Avatar
    Join Date
    Sep 2001
    Posts
    937
    After a while, it becomes fruitless to debate which language features to use, and which ones not to use. The first time, maybe. Or when there's a new hire. Or when you transfer to another team. But at this point, I'm tired. Give me constraints, and I will force the problem through it.

    Besides, this Validated<Config> thing is pretty neat. bless is a particular kind of ugly, but I'm not over it yet.
    Last edited by CodeMonkey; 08-22-2022 at 05:27 PM. Reason: positive note
    "If you tell the truth, you don't have to remember anything"
    -Mark Twain

Popular pages Recent additions subscribe to a feed

Similar Threads

  1. Struggling to Fix a Phase shift
    By mtde226 in forum C Programming
    Replies: 5
    Last Post: 05-16-2017, 06:16 AM
  2. Patterns to produce "meaningful" patterns
    By Aslaville in forum Tech Board
    Replies: 5
    Last Post: 03-14-2015, 12:46 AM
  3. Bit Patterns
    By cmp in forum C Programming
    Replies: 1
    Last Post: 03-08-2014, 06:00 PM
  4. Patterns and anti-patterns
    By Neo1 in forum C++ Programming
    Replies: 3
    Last Post: 10-23-2013, 05:30 PM
  5. Patterns
    By Unregistered in forum A Brief History of Cprogramming.com
    Replies: 6
    Last Post: 04-29-2002, 04:02 PM

Tags for this Thread