Why is throwing exceptions not considered a technique to return different data types at runtime?

205 views Asked by At

I've this in mind:

// This is called at multiple locations and each handles the returned object
// differently. Handling also differs depending on the returned object type.
void GetObject() {
    // Check runtime condition

    // Object1 and Object2 are completely dissimilar and do not share a base class.
    if (condition1) {
        // Operations to prepare for construction of Object1
        throw Object1{ var1, var2, var3 };
    }
    if (condition2) {
        // Operations to prepare for construction of Object2
        throw Object2{ var4, var5 };
    }
    throw MyException{};
}

// Usage example
int main() {
    try {
        GetObject();
    }
    catch (const Object1& obj1) {
        // Object1-specific handling
    }
    catch (const Object2& obj2) {
        // Object2-specific handling
    }
    catch (const MyException& e) {
        // Error handling
    }
}

Curiously, the answers to questions about returning different data types do not mention this technique at all:

Is there any particular reason for this? The only reason I can think of is that it's unorthodox, otherwise this seems like the simplest and cleanest approach - no 3rd party libraries, no need to upgrade to C++17, no additional abstractions.

Update

As many have pointed out in the comments, std::variant would solve the problem nicely, but it requires C++17 or the boost library; unfortunately my project is unable to use either at the moment.

Update 2

Thanks everyone for your inputs. They were very helpful in making me think through the various design options and highlight areas where I overlooked. I've selected the answer below as it directly addresses the reason why this technique is not considered for use. The other 2 answers proposed alternative techniques, so do take a look at them if you have similar use cases. For me, I eventually decided on output parameters as the solution due to its simplicity and also my constraints of no C++17 and no boost:

bool GetObject(
    std::unique_ptr<Object1>& obj1,
    std::unique_ptr<Object2>& obj2) {
    // Check runtime condition

    if (condition1) {
        // Operations to prepare for construction of Object1
        obj1 = std::make_unique<Object1>(var1, var2, var3);
        return true;
    }
    if (condition2) {
        // Operations to prepare for construction of Object2
        obj2 = std::make_unique<Object2>(var4, var5);
        return true;
    }
    return false;
}

// Usage example
int main() {
    std::unique_ptr<Object1> obj1;
    std::unique_ptr<Object2> obj2;
    if (!GetObject(obj1, obj2)) {
        // Error handling
        return -1;
    }
    if (obj1) {
        // Object1-specific handling
    }
    else {
        // Object2-specific handling
    }
    ...
}

Instead of unique_ptr, statically allocating the objects (i.e. on the stack) will also work if the objects aren't too large. But this requires Object1 and Object2 to have member functions that can tell callers whether the class has been initialized.

3

There are 3 answers

0
Jan Schultke On BEST ANSWER

Exceptions are too costly for polymorphism

Exceptions are simply the wrong mechanism for polymorphism. Throwing an exception is an extremely expensive operation: Graph which compares the relative cost of different benchmarks. All results related to use_error_code are fairly low (50 or lower), but the use_exception bars get dramatically higher (over 4000 for use_exception90) the greater the probability of throwing an exception is.
Source: @Arash's answer containing this benchmark.

In this benchmark, the cost of returning an error code is mostly the same no matter how great the probability is. Throwing an exception can be over 100x slower. It's expensive because

  • The stack must be unwound. This is effectively single-threaded and requires synchronization, see P2544: C++ exceptions are becoming more and more problematic.
  • Code is run which is probably not cached because exception handling code is never executed normally.
  • Exceptions are allocated in dynamic memory.

If exceptions are so expensive, when do we use them?

Exceptions are zero-overhead as long as you never throw them, but if you ever do, you're in for a wild ride. That's why, as the name says, they are used for exceptional situations, like:

  • running out of memory on an allocation
  • getting an unexpected error from a driver or the operating system
  • developer mistakes like accessing the wrong index on std::vector::at

In a regular program execution, an exception should never be thrown.

Alternative ways to return one of N types

This topic has already been discussed to death in the threads you've linked, but just to give you a few options:

  • std::variant (C++17)
  • std::optional (C++17) and std::expected (C++23) in specific cases
  • polymorphic classes
    • possibly wrapped in std::unique_ptr (C++11)
  • struct which simply contains both types, and a tag to say which is active
    • possibly less efficient than a proper union, but trivial to implement
  • avoiding runtime polymorphism entirely with static polymorphism
    • possibly through templates

Your example is a bit too minimal to say which way works best here.

14
Pepijn Kramer On

These are two of the idiomatic way C++ works with polymorphism.

#include <memory>
#include <variant>
#include <iostream>

// Option 1, use std::variant
// and "static" (compile time) polymorphism
struct Object1
{
    void DoSomething() 
    {
        std::cout << "Hello from Object1\n";
    };
};

struct Object2
{
    void DoSomething() 
    {
        std::cout << "Hello from Object2\n";
    };
};

std::variant<Object1, Object2> GetObject(bool return_one)
{
    if (return_one)
        return Object1{};
    else
        return Object2{};
}

// Or use dynamic (runtime) polymorphism
struct ObjectItf
{
    virtual ~ObjectItf() = default;
    virtual void DoSomething() = 0;
};

struct DynObject1 : public ObjectItf
{
    void DoSomething() override
    {
         std::cout << "Hello from DynObject1\n";
    }
};

struct DynObject2 : public ObjectItf
{
    void DoSomething() override
    {
         std::cout << "Hello from DynObject2\n";
    }
};

std::unique_ptr<ObjectItf> GetDynObject(bool return_one)
{
    if (return_one)
        return std::make_unique<DynObject1>();
    else
        return std::make_unique<DynObject2>();
}


int main()
{
    auto object = GetObject(true);
    std::visit([](auto& object){ object.DoSomething(); }, object);

    auto object_itf = GetDynObject(true);
    object_itf->DoSomething();

    return 0;
}
5
463035818_is_not_an_ai On

Honestly, I do not understand what this actually achieves. I think your example is too simplified to demonstrate a real use case. Once you need the "returned" objects outside of the catch blocks you are back to square one. Because you then again need something that can hold either an Object1 or an Object2.

On the other hand if you are fine with not getting the objects out of the catch block and having them available on such limited scope, then there are much simpler solutions that are less arcance. What comes to my mind first is a functor with overloaded operator():

struct Object1 {};
struct Object2 {};

struct handler {
    void operator()(const Object1&) const { }
    void operator()(const Object2&) const { }
};

void GetObject(const handler& h) {
    // Check runtime condition
    // Object1 and Object2 are completely dissimilar and do not share a base class.
    if (2%2)
    {
        h(Object1{});
    }
    if (1<1)
    {
        h(Object2{});
    }
}

This can be made more flexible, eg handler can have the actual functions to be executed in either case injected at runtime. handler could even store the objects to be picked up later. You do not need to abuse exceptions to achieve what your code achieves.


You asked for different function being executed in the two overloads and usage of local variables from different call sites. As mentioned above, you can inject the functions to be called at runtime, and if you need you can also capture local variables:

template <typename F1,typename F2>
void GetObject(F1&& f1, F2&& f2) {
    // Check runtime condition
    // Object1 and Object2 are completely dissimilar and do not share a base class.
    if (2%2)
    {
        f1(Object1{});
    }
    if (1<1)
    {
        f2(Object2{});
    }
}

int main() {
    int x = 42;
    int y = 102;
    GetObject([&](const Object1&){ std::cout << x;},[&](const Object2&){ std::cout << y;});
}