312 | | == 8. DESIGN: (Strongly) consider using monadic transport types to return state from functions == |
| 312 | == 8. DESIGN: (Strongly) consider using constexpr semantic wrapper transport types to return states from functions == |
| 313 | |
| 314 | Thanks to constexpr and rvalue refs, C++ 11 codebases have much superior ways of returning states from functions. Let us imagine this C++ 11 function: |
| 315 | |
| 316 | {{{#!c++ |
| 317 | std::shared_ptr<handle_type> openfile(std::filesystem::path path) |
| 318 | { |
| 319 | int fd; |
| 320 | while(-1==(fd=::open(path.c_str(), O_RDWR|O_EXCL)) && EINTR==errno); |
| 321 | if(-1==fd) |
| 322 | { |
| 323 | int code=errno; |
| 324 | std::error_code ec(code, generic_category()); |
| 325 | std::string errstr(strerror(code)); |
| 326 | throw std::system_error(ec, std::move(errstr)); |
| 327 | } |
| 328 | return std::make_shared<handle_type>(fd); |
| 329 | } |
| 330 | }}} |
| 331 | |
| 332 | This is a highly simplified example, but an extremely common pattern in one form or another: when C++ code calls something not C++ and it returns an error, convert it into an exception and throw it. Else construct and return a RAII holding smart pointer to manage the resource just acquired. |
| 333 | |
| 334 | The really nice thing about this highly simple design is that its API nicely matches its semantic meaning: if it succeeds you always get a shared_ptr. If it fails you always get an exception throw. Easy. |
| 335 | |
| 336 | Unfortunately, throwing exceptions has unbounded time guarantees due to RTTI lookups, so for any code which worries about complexity guarantees the above is unacceptable: ''throwing exceptions should be exceptional'' as the purists would put it. So traditionally speaking the 03 pattern is to provide an additional overload capable of writing into an error_code, this being the pattern traditionally used by ASIO and most Boost libraries. That way if the error_code taking overload is chosen, you get an error code instead of exception but code is still free to use the always throwing overload above: |
| 337 | |
| 338 | {{{#!c++ |
| 339 | std::shared_ptr<handle_type> openfile(std::filesystem::path path, std::error_code &ec) |
| 340 | { |
| 341 | int fd; |
| 342 | while(-1==(fd=::open(path.c_str(), O_RDWR|O_EXCL)) && EINTR==errno); |
| 343 | if(-1==fd) |
| 344 | { |
| 345 | int code=errno; |
| 346 | ec=std::error_code(code, generic_category()); |
| 347 | return std::shared_ptr<handle_type>(); // Return a null pointer on error |
| 348 | } |
| 349 | return std::make_shared<handle_type>(fd); // This function can't be noexcept as it can throw bad_alloc |
| 350 | } |
| 351 | }}} |
| 352 | |
| 353 | This pushes the problem of checking for error conditions and interpreting error codes onto the caller, which is okay if a little potentially buggy if the caller doesn't catch all the outcomes. Note that code calling this function must still be exception safe in case bad_alloc is thrown. One thing which is lost however is semantic meaning of the result, so above we are overloading a null shared_ptr to indicate when the function failed which requires the caller to know that fact instead of instantly being able to tell from the API return type. Let's improve on that with a std::optional<T>: |
| 354 | |
| 355 | {{{#!c++ |
| 356 | namespace std { using std::experimental; } |
| 357 | std::optional<std::shared_ptr<handle_type>> openfile(std::filesystem::path path, std::error_code &ec) |
| 358 | { |
| 359 | int fd; |
| 360 | while(-1==(fd=::open(path.c_str(), O_RDWR|O_EXCL)) && EINTR==errno); |
| 361 | if(-1==fd) |
| 362 | { |
| 363 | int code=errno; |
| 364 | ec=std::error_code(code, generic_category()); |
| 365 | return std::nullopt; |
| 366 | } |
| 367 | return std::make_optional(std::make_shared<handle_type>(fd)); |
| 368 | } |
| 369 | }}} |
| 370 | |
| 371 | So far, so good, though note we can still throw exceptions and all of the above worked just fine in C++ 03 as Boost provided an optional<T> implementation for 03. However the above is actually semantically suboptimal now we have C++ 11, because C++ 11 lets us encapsulate far more semantic meaning which is cost free at runtime using a monadic transport like Boost.Expected: |
| 372 | |
| 373 | {{{#!c++ |
| 374 | namespace std { using std::experimental; } |
| 375 | std::expected<std::shared_ptr<handle_type>, std::error_code> openfile(std::filesystem::path path) |
| 376 | { |
| 377 | int fd; |
| 378 | while(-1==(fd=::open(path.c_str(), O_RDWR|O_EXCL)) && EINTR==errno); |
| 379 | if(-1==fd) |
| 380 | { |
| 381 | int code=errno; |
| 382 | return std::make_unexpected(std::error_code(code, generic_category()); |
| 383 | } |
| 384 | return std::make_shared<handle_type>(fd); |
| 385 | } |
| 386 | }}} |
| 387 | |
| 388 | The expected outcome is a shared_ptr to a handle_type, the unexpected outcome is a std::error_code, and the catastrophic outcome is the throwing of bad_alloc. Code using `openfile()` can either manually check the expected (its bool operator is true if the expected value is contained, false if the unexpected value) or simply unilaterally call `expected<T>.value()` which will throw if the value is unexpected, thus converting the error_code into an exception. As you immediately note, this eliminates the need for two `openfile()` overloads because the single monadic return based implementation can now perform ''both'' overloads with equal convenience to the programmer. On the basis of halving the number of APIs a library must export, use of expected is a huge win. |
| 389 | |
| 390 | However I am still not happy with this semantic encapsulation because it is a poor fit to opening files. Experienced programmers will instantly spot the problem here: the `open()` call doesn't just return success vs failure, it actually has five outcome categories: |
| 391 | |
| 392 | 1. Success, returning a valid fd. |
| 393 | 2. Temporary failure, please retry immediately: EINTR |
| 394 | 3. Temporary failure, please retry later: EBUSY, EISDIR, ELOOP, ENOENT, ENOTDIR, EPERM, EACCES (depending on changes on the filing system, this could disappear or appear at any time) |
| 395 | 4. Non-temporary failure due to bad or incorrect parameters: EINVAL, ENAMETOOLONG, EROFS |
| 396 | 5. Catastrophic failure, something is very wrong: EMFILE, ENFILE, ENOSPC, EOVERFLOW, ENOMEM, EFAULT |
| 397 | |
| 398 | So you can see the problem now: what we really want is for category 3 errors to only return with error_code, whilst category 4 and 5 errors plus bad_alloc to probably emerge as exception throws. That way the C++ semantics of the function would exactly match the semantics of opening files. So let's try again: |
| 399 | |
| 400 | {{{#!c++ |
| 401 | namespace std { using std::experimental; } |
| 402 | std::expected<std::expected<std::shared_ptr<handle_type>, std::error_code>, std::exception_ptr> openfile(std::filesystem::path path) noexcept |
| 403 | { |
| 404 | int fd; |
| 405 | while(-1==(fd=::open(path.c_str(), O_RDWR|O_EXCL)) && EINTR==errno); |
| 406 | try |
| 407 | { |
| 408 | if(-1==fd) |
| 409 | { |
| 410 | int code=errno; |
| 411 | if(EBUSY==code || EISDIR==code || ELOOP==code || ENOENT==code || ENOTDIR==code || EPERM==code || EACCES==code) |
| 412 | return std::make_unexpected(std::error_code(code, generic_category()); |
| 413 | std::string errstr(strerror(code)); |
| 414 | return std::make_unexpected(std::make_exception_ptr(std::system_error(ec, std::move(errstr)))); |
| 415 | } |
| 416 | return std::make_shared<handle_type>(fd); |
| 417 | } |
| 418 | catch(...) |
| 419 | { |
| 420 | return std::make_unexpected(std::current_exception()); |
| 421 | } |
| 422 | } |
| 423 | }}} |
| 424 | |
| 425 | There are some very major gains now in this design: |
| 426 | |
| 427 | 1. Code calling `openfile()` no longer need to worry about exception safety - all exceptional outcomes are always transported by the monadic expected transport. This lets the compiler do better optimisation, eases use of the function, and leads to few code paths to test which means more reliable, better quality code. |
| 428 | 2. The semantic outcomes from this function in C++ have a close mapping to that of opening files. This means code you write more naturally flows and fits to what you are actually doing. |
| 429 | 3. Returning a monadic transport means you can now program monadically against the result e.g. `value_or()`, `then()` and so on. Monadic programming is also a formal specification, so you could in some future world use a future clang AST tool to formally verify the mathematical correctness of some monadic logic. That's enormous for C++. |
| 430 | |
| 431 | You may have noticed though the (Strongly) being in brackets, and if you guessed there are caveats then you are right. The first big caveat is that the expected<T, E> implementation in Boost.Expected is very powerful, but unfortunately has a big negative effect on compile times, and that rather ruins it for practical use for a lot of people. The second caveat is that integration between Expected and Future-Promise especially with resumable functions in the mix is currently poorly defined, and using Expected now almost certainly introduces immediate technical debt into your code. |
| 432 | |
| 433 | The third caveat is that I personally plan to write a much lighter weight monadic result transport which isn't as flexible as expected<T, E> (and probably hard coded to a T, error_code and exception_ptr outcomes) but would have negligible effects on compile times, and very deep integration with a non-allocating all-constexpr new lightweight future-promise implementation. Once implemented, my transport may be disregarded by the community, evolved more towards expected<T, E>, or something else entirely may turn up. |
| 434 | |
| 435 | In other words, I recommend you very strongly consider ''some'' mechanism for cleanly matching C++ semantics with what a function does now that C++ 11 makes it possible, but I unfortunately cannot categorically recommend one solution over another at the time of writing. |