#error propagation through return types

75 messages · Page 1 of 1 (latest)

deft crag
#

my personal issue: i dont like std::expected, it feels verbose to use. so my core idea was "hey, what if we could use structured bindings on the error type to help along?".

so i spend today writing two types, result<T> and report<T>. the only difference is that one contains a numerical result and the other a textual result. i would really appreciate some feedback on how i designed the interface. on what is dangerous to allow and what can be improved. personally i think its pretty solid except for one minor nitpick i cant fix. but this isnt about that. really just a "what do you think of this return type api?"

#
export struct error_msg {
    string m_message;
    error_msg() = default;
    error_msg(cstr msg) : m_message(msg) {}
    error_msg(string_view msg) : m_message(msg) {}
    bool failed() const { return !m_message.empty(); }
    operator string_view() const { return m_message; }
};

export enum class error_code : i32 {
    ok = 0, fail = 1000,
    invalid_argument, out_of_range, not_implemented,
    file_not_found, permission_denied, read_error, write_error, end_of_file,
    connection_failed, timeout, host_unreachable, protocol_error,
    out_of_memory, resource_busy, resource_unavailable,
    parse_error, format_error, validation_failed,
    deadlock_detected, lock_failed, thread_error
};

template<typename T, typename E>
struct base_result {
    base_result() = default;
    base_result(const T& value) : m_value(value), m_error() {}
    base_result(T&& value) : m_value(move(value)), m_error() {}
    base_result(const E& error) : m_value(), m_error(error) {}
    base_result(E&& error) : m_value(), m_error(move(error)) {}

    operator tuple<T&, E&>()& { return { m_value, m_error }; }
    operator tuple<T, E>()&& { return { move(m_value), move(m_error) }; }
    operator tuple<const T&, const E&>() const& { return { m_value, m_error }; }

    operator T&&()&& { return move(m_value); }

    operator bool() const { return m_error == E(); }

    const T& value() const& { return m_value; }
    T&& value()&& { return move(m_value); }
    const E& error() const& { return m_error; }
    E&& error()&& { return move(m_error); }

    T m_value = {};
    E m_error = {};
};

export template<typename T> using result = base_result<T, error_code>;
export template<typename T> using report = base_result<T, error_msg>;

export error_code error(error_code code) { return error_code(code); }
export error_msg error(cstr msg) { return error_msg(msg); }
export error_msg error(string_view msg) { return error_msg(msg); }
#

Usage

lf::result<lf::string> readFile(string_view path);
lf::report<BinarySPV> compileShader(string_view source);

void foo() {
    auto [content, fileError] = readFile("shader.vert");
    if (fileError) { return; }
    
    auto [binary, compileError] = compileShader(content);
    if (compileError) { std::cout << compileError << "\n"; return; }
    
    // successfully compiled the shader
    // some other things:
    std::tie(content, fileError) = readFile("shader.frag");
    lf::string other = readFile("other.txt"); // when the result is an r value, allow for implicit conversion to ignore errors
}
ocean nexus
#

what would the corresponding thing look like with std::expected? I don't really see how this is an improvement

thick cradle
#

this seems like a std::expected<T,error_code> except you always have to return a default/empty T even in the error path

deft crag
#

just asked chatgpt to generate a mirror lol

void foo() {
    auto contentExp = readFile("shader.vert");
    if (!contentExp) { 
        return; 
    }
    string content = contentExp.value();
    
    auto binaryExp = compileShader(content);
    if (!binaryExp) { 
        std::cout << binaryExp.error() << "\n"; 
        return; 
    }
    BinarySPV binary = *binaryExp;

    // Successfully compiled the shader
    // Some other things:
    auto fragExp = readFile("shader.frag");
    string fragContent;
    if (fragExp) {
        fragContent = *fragExp;
    }

    // Ignore errors for rvalue example
    string other = readFile("other.txt").value_or(""); // default empty string on error
}
#

i especially like the structured bindings. you dont need to put a result on the stack but can immediatly put it into two separate variables

ocean nexus
#
if (!result) {
    return …
}

fun(*result);
#

is all it takes

deft crag
#

now you also gotta move the result out unless ofc you want to copy it

ocean nexus
#

the structured binding would mean that you've always got one name that refers to a meaningless entity? since you either have a result or an error, never both?

ocean nexus
deft crag
#

auto [content, fileError] = readFile("shader.vert"); this does a move

#

because the result is an r value

ocean nexus
#

well, same with expected 🤷‍♂️

thick cradle
#

you could also invest in some macros to do the error propagation automatically

#
TRY_OK(auto event =, win32::event::create());
deft crag
#

where do you define that

thick cradle
#

?

#

in the macro

deft crag
#

so every failure gets handled the same?

deft crag
thick cradle
#

not portably, no

thick cradle
#

that's what propagation means 😛

#

if you want to handle it differently, then you gotta do that of course

deft crag
#

i have an idea wai

#
string content;
{
    auto result = readFile();
    if (!result) {
        // error scope gets defined on the outside somehow
    }
    content = *result
}

somehow make the macro expand to this?

#

that gets rid of all the temporary variables that annoy me

ocean nexus
thick cradle
#

still requires default initialization of T which means it won't be possible to use with tons of things

ocean nexus
#

?? really can't come soon enough

deft crag
thick cradle
#

yeah that's just horrible

deft crag
#

more and more thinking that i only like my design cuz i made it

thick cradle
#

so far, most of the std::expectified code I have looks like

TRY_HR(device.factory->CreateSwapChainForHwnd(device.graphics_queue->command_queue.get(), hwnd, &swapchain_desc, nullptr, nullptr, swapchain.put()));
TRY_OK(device.swapchain =, swapchain.as<IDXGISwapChain4>());

TRY_HR(device.factory->MakeWindowAssociation(hwnd, DXGI_MWA_NO_ALT_ENTER));

device.frame_index = device.swapchain->GetCurrentBackBufferIndex();
TRY_OK(device.rtv_descriptor_heap =, DescriptorHeap::create(*device.device, D3D12_DESCRIPTOR_HEAP_TYPE_RTV, frame_count));
#

sure, this leaves a few result_1786234 local variables lying around, but the local variable on the success path is only created if it actually has a value

#

(or, well, in this case I just assign to some existing variable, but whatever)

ocean nexus
#

I'm just gonna say it: exceptions are pretty great actually.

thick cradle
#

mods

deft crag
#

nuh uh 😭

#

i have this really deep hate for exceptions. im not like "never" use them because every tool has a job but exceptions for me will always crash parts of the application

ocean nexus
#

sry but if exceptions just crash your app then your code is just bad.

deft crag
#

no not litterally crash, they get handlded. but eg when an exception arises in game code it takes you back to the main menu

#

thats where i would use them

ocean nexus
#

well, that's what most errors probably will result in?

deft crag
#

nooo i mean.. ugh i cant explain myself well which probably means i have no point

ocean nexus
#

taking your example above: what kind of local handling were you envisioning for the case where reading an asset from disk fails xD

#

there's not much you generally can do in that case other than show an error and exit?

thick cradle
#

criminal

#

you show a pink black checkerboard default shader instead and keep running smh

deft crag
#

you try again :)

thick cradle
#

maybe the shader compiler will reconsider? yamikek

ocean nexus
#

right, because failed io tends to work the second time 😛

#

sometimes, all it takes is for the OS to look a little harder at that corrupted sector

deft crag
#

i hate that you are making sense

#

to be truthful ill prob just use the things i made because i made them and want to use them and need to feel them to see that i am indeed wrong and exceptions are better. but on the bright side i still learned about ref qualifiers today so. not all for nothing

#

but i really like having some more perspectiev

ocean nexus
#

fwiw, I actually think that the true answer to error handling is the combination of both. you want your fundamental operations to give you result types that you can unwrap to just turn them into an exception.

#

that way, the caller gets to decide what error handling strategy they want to use. and it's usually only the caller that really can make that decision.

deft crag
#

tysm for the help

#

i really appreciate it

ocean nexus
#

np ^^

deft crag
thick cradle
ocean nexus
ocean nexus
heavy field
thick cradle
#

maybe you're thinking of from_chars result?

heavy field
#

I don't remember as of now. I remember a colleague of mine talking about it when we were giving a co-lecture on modern C++