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?