trainings/CreatingReliableSoftwareCpp/Presentation/exceptions.md

12 KiB
Raw Blame History

Exceptions


Throw

If you want to throw an exception you can simply call throw

void foo() {
    throw "Error";
}

If you want to catch an error you need to use try-catch

void bar() {
    try {
        foo();
    } catch (const std::exception& err) {
        std::cout << err.what() << '\n';
    }
}

What happens during throw?

Programm will stop executing the next line, when reach throw command. It will startup unwind a stack untill it reach the first try-catch block which will catch this exception. Because the stack is unwinded all variables which go out of scope will be properly destructed.

struct Foo {
    explicit Foo(int id): id(id) { std::cout << "C'tor id: " << id << "\n"; }
    ~Foo() { std::cout << "D'tor id: " << id << "\n"; }

    int id;
};

void fun2() {
    Foo foo(2);
    std::cout << "This will print!\n";
    throw std::runtime_error("Bad!");
    std::cout << "This will not print!\n";
}

void fun1() {
    std::cout << "We start here!\n";
    Foo foo(1); 
    std::cout << "After construction of Foo we call function fun2 which throw\n";     
    fun2();
    std::cout << "This will not print!\n";
}

int main() { 
    try {
        fun1();
        std::cout << "This will not print!\n";
    } catch (const std::exception& err) {
        std::cout << "exception: " << err.what() << '\n';
    }
    std::cout << "Exit\n";
}
We start here!
C'tor id: 1
After construction of Foo we call function fun2 which throw       
C'tor id: 2
This will print!
D'tor id: 2
D'tor id: 1
exception: Bad!
Exit
___

Hierarchy

There are more than 30 exceptions in C++! Each exception could be a base class of other (see cppreference). The most common are:

  • logic_error
  • out_of_range
  • bad_optional_access
  • runtime_error
  • bad_weak_ptr
  • bad_alloc

How to properly catch an error

void doSth() {
    std::cout << "Hello! ";
    throw std::invalid_argument("SthBad!");
}  

int main() {
    try {
        doSth();
    } catch (const std::invalid_argument& err) {
        std::cout << "invalid_argument: " << err.what() << '\n';
    } catch (const std::logic_error& err) {
        std::cout << "logic_error: " << err.what() << '\n';
    } catch (const std::exception& err) {
        std::cout << "exception: " << err.what() << '\n';
    } catch (...) {
        std::cout << "Undefined error!\n";
    }
}

Output: Hello! invalid_argument: SthBad!.


What happens when I will try to catch an error with an unordered hierarchy?

void doSth() {
    std::cout << "Hello! ";
    throw std::invalid_argument("SthBad!");
}  

int main() {
    try {
        doSth();
    } catch (const std::logic_error& err) {
        std::cout << "logic_error: " << err.what() << '\n';
    } catch (const std::invalid_argument& err) {
        std::cout << "invalid_argument: " << err.what() << '\n';
    } catch (...) {
        std::cout << "Undefined error!\n";
    }
}

Output: Hello! logic_error: SthBad!

main.cpp: In function int main():
main.cpp:21:7: warning: exception of type std::invalid_argument will be caught
   21 |     } catch (const std::invalid_argument& err) {
      |       ^~~~~
main.cpp:19:7: warning:    by earlier handler for std::logic_error
   19 |     } catch (const std::logic_error& err) {

You can quickly create your error class by inheriting from the base class std::exception. We need to override one method which will return a proper error message: virtual const char* what() const noexcept

class MyError : public std::exception {
public:
    explicit MyError(const std::string& what): _what(what) {}
    explicit MyError(const char* what): MyError(std::string(what)) {}
    explicit MyError(std::string_view what): MyError(std::string(what)) {}
    const char* what() const noexcept override { return _what.c_str(); }
private:
    std::string _what;
};

class OtherError : public MyError {
public:
    using MyError::MyError;
};

void doSth() {
    throw OtherError("SthBad!");
}  

int main() {
    try {
        doSth();
    } catch (const OtherError& err) {
        std::cout << "OtherError: " << err.what() << '\n';
    } catch (const MyError& err) {
        std::cout << "MyError: " << err.what() << '\n';
    } catch (const std::exception& err) {
        std::cout << "exception: " << err.what() << '\n';
    }
}

It's important to use your error classes instead of those defined by the standard library. It will allow you to determine if the error comes from the standard library or your functions.

class MyError : public std::exception {
public:
    explicit MyError(const std::string& what): _what(what) {}
    const char* what() const noexcept override { return _what.c_str(); }
private:
    std::string _what;
};

class Foo {
public:
    const std::string& getProcessName(uint64_t pid) { return _processes.at(pid); } 
    void addProcess(uint64_t pid, const std::string& processName) {
        if (_processes.contains(pid)) {
            throw MyError(std::format("Process with PID {} already exist", pid));
        }
        _processes[pid] = processName;
    }
private:
    std::map<uint64_t, std::string> _processes;
};

int main() {
    try {
        Foo foo;
        const auto& processName = foo.getProcessName(20);
        foo.addProcess(1234, "MySuperprocess!");
    } catch (const MyError& err) {
        std::cout << "MyError: " << err.what() << '\n';
    } catch (const std::exception& err) {
        std::cout << "exception: " << err.what() << '\n';
    }
}

Ways to signal an error

  • throw an exception
  • return enum class ErrorCode
  • return nullptr
  • return false
  • return std::optional (C++17)
  • return std::expected (C++23)

Descriptive errors

We have three ways to strictly describe what happens wrong: exception, enum class, and std::expected. Using these types we can say what exactly goes wrong, like: providing the wrong credentials, or we can't find some element, etc...

int foo(const std::string& num) {
    if (num != "42") {
        throw std::runtime_error("Number is different than 42!");      
    }

    return 42;
}

enum class StatusCode {
    Ok,
    Not42Number
};

StatusCode foo2(const std::string& num, int& res) {
    if (num != "42") {
        return StatusCode::Not42Number;
    }

    res = 42;
    return StatusCode::Ok;
}
enum class ParserError {
    invalid_input,
    overflow
};
 
std::expected<double, ParserError> parse(std::string_view str) {      
    if (str.size() > 1'000) {
        return std::unexpected(ParserError::overflow);
    } else if (str.empty()) {
        return std::unexpected(ParserError::invalid_input);
    }
    
    return 42;
}
 
int main() {
    if (const auto num = parse("something")) {
        std::cout << "value: " << *num << '\n';
    } else if (num.error() == ParserError::overflow) {
        std::cout << "error: overflow\n";
    } else if (num.error() == ParserError::invalid_input) {
        std::cout << "invalid_input!\n"; 
    }
}
___

Non descriptive errors

There are also three ways how to inform about the error but without describing why. This is useful for simple functions that may have only one error like whether there is a value inside the map or not.

std::unique_ptr<int> foo3(const std::string& num) {
    if (num != "42") {
        return nullptr;
    }

    return std::make_unique<int>(42);
}

bool foo4(const std::string& num, int& res) {
    if (num != "42") {
        return false;
    }

    res = 42;
    return true;
}

std::optional<int> foo5(const std::string& num) {
    if (num != "42") {
        return std::nullopt;
    }

    return 42;
}

std::optional - more info

Introduced in c++17 std::optional is a useful class that may have a value or not.

  • Doesn't allocate data on heap!
  • Require additional storage info -> bigger size
  • Not as efficient as normal integer -> need to perform additional actions
class Foo {
    int a;
    int b;
    int c;
    int d;
};

int main() {
    std::cout << sizeof(int) << '\n';
    std::cout << sizeof(std::optional<int>) << '\n';
    std::cout << sizeof(Foo) << '\n';
    std::cout << sizeof(std::optional<Foo>) << '\n';
}
4
8
16
20

std::expected - more info

Introduced in c++23 std::expected is a useful class that may store one of two values:

  • expected -> the return value which we expect
  • unexpected -> the value that will be returned when something goes wrong

In most cases as an unexpected value, we return an enum code with a description of what went wrong, but we can return anything (except reference)

struct Foo {
    void print() const { std::cout << "Sorry your function doesn't work!"; }
};

std::expected<std::string, Foo> parse(std::string_view str) {
    if (str.empty()) {
        return std::unexpected(Foo{});
    }
    return "42";
}
 
int main() {
    if (const auto num = parse("")) {  
        std::cout << "value: " << *num << '\n'; 
    } else {
        num.error().print();
    }
}
Sorry your function doesn't work!

Exercise

  • Create an exception class `Exception` which inherits from `std::exception` and two classes `ParserException` and `ReaderException` which inherit form class `Exception`.
  • Rewrite a code to `ParserException` in the method `parse` and `ReaderException` in the method `read`.
  • Try to catch errors in `main` and print them
  • Rewrite a code once again and return `nullopt` when an error occurs instead of throwing an exception