Changes between Version 13 and Version 14 of BestPracticeHandbook


Ignore:
Timestamp:
May 7, 2015, 2:03:58 PM (7 years ago)
Author:
Niall Douglas
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • BestPracticeHandbook

    v13 v14  
    310310
    311311
    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
     314Thanks 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++
     317std::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
     332This 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
     334The 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
     336Unfortunately, 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++
     339std::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
     353This 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++
     356namespace std { using std::experimental; }
     357std::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
     371So 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++
     374namespace std { using std::experimental; }
     375std::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
     388The 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
     390However 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
     3921. Success, returning a valid fd.
     3932. Temporary failure, please retry immediately: EINTR
     3943. 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)
     3954. Non-temporary failure due to bad or incorrect parameters: EINVAL, ENAMETOOLONG, EROFS
     3965. Catastrophic failure, something is very wrong: EMFILE, ENFILE, ENOSPC, EOVERFLOW, ENOMEM, EFAULT
     397
     398So 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++
     401namespace std { using std::experimental; }
     402std::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
     425There are some very major gains now in this design:
     426
     4271. 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.
     4282. 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.
     4293. 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
     431You 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
     433The 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
     435In 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.
    313436
    314437