fenbf / cppstories-discussions

4 stars 1 forks source link

2024/expected-cpp23/ #138

Open utterances-bot opened 9 months ago

utterances-bot commented 9 months ago

Using std::expected from C++23 - C++ Stories

In this article, we’ll go through a new vocabulary type introduced in C++23. std::expected is a type specifically designed to return results from a function, along with the extra error information. Motivation   Imagine you’re expecting a certain result from a function, but oops… things don’t always go as planned.

https://www.cppstories.com/2024/expected-cpp23/

PiotrNycz commented 9 months ago

It is very inconvienient if a function returning std::expected is not marked as noexcept. Because that means - you have implemented two error handling strategies in that functions - it either returns and error std::expected or returns as error by throwing exception.

Mike4Online commented 9 months ago

Sy Brand developed the reference implementation of expected. Sy's version supports C++11 or newer; it is available at https://github.com/TartanLlama/expected. It supports the monadic extensions, too.

robertgbjones commented 9 months ago

Hmm... std:: expected is worthy effort, but without the language support offered by Rust it is just one of many error handling strategies. The power of Rust's Result<...> type is that it is the ubiquitous mechanism and that Rust demands all enumerations are considered.

dtowell commented 9 months ago

Yes, the following are nitpicks, but I thot you would want to know: 1) your first two examples differ in a non-helpful way: record.isValid() vs record.valid 2) second example prints record.value().valid, but I think you mean record.value()

fenbf commented 9 months ago

Thanks @dtowell. I improved the example! value().valid is ok, as there;'s no operator << for the DatabaseRecord structure.

fenbf commented 9 months ago

good point @PiotrNycz ! so you're saying that all functions that return std::expected should be noexcept? But what if we use something that might throw - like std::string - and allocation fails... should we then use try/catch inside the function to catch it and don't leak outside?

2kaud commented 9 months ago

IMO exceptions should be for the unexpected and std::exception should be for the expected. There are many circumstances where an error can be expected. The conversion of a string to a number is one. If the conversion fails, should an exception be thrown? IMO if it's from user input then probably not and in that case std::exception is the way to go. Does that mean that functions that return std::exception should always be noexcept? IMO no. The two can be used together. Return std::exception for valid and expected issues and exceptions for unexpected issues - and especially those from which can't ( be easily) recovered.

"I don’t think we can/should use it as data members"

Hmm. Isn't std::exception basically just a 'pair of optional' with different ways of accessing the first and second elements? and can't it be treated as just that in its usage?

Mike4Online commented 9 months ago

IMO exceptions should be for the unexpected and std::exception should be for the expected.

A function should never return expected results via any kind of exception.

When you want to throw an exception, it could be:

Does that mean that functions that return std::exception should always be noexcept?

A function that is capable of returning any exception should never be marked noexcept; otherwise, the program will terminate immediately if an exception occurs (unless you play with the handler for this).

Mike4Online commented 9 months ago

But what if we use something that might throw - like std::string - and allocation fails... should we then use try/catch inside the function to catch it and don't leak outside?

Yes, you can wrap the std::string instantiation within try..catch to handle the exception and ensure it does not escape the function you've marked noexcept. This can make it challenging to write functions that are both noexcept and well-designed. You have to know what needs to be wrapped in try..catch and how to handle every possible caught exception. You have to do this in any destructor that calls a function that could throw, since destructors are implicitly noexcept by default. Sometimes in a destructor I simply 'swallow' the exception and don't do anything with it, but that is probably bad practice.

Bartłomiej, please write a blog article to dive into the craft of writing proper noexcept functions. Handling or avoiding heap allocation failures would be one kind of exception to deal with; perhaps this would be a good starting point.

I've considered creating a memory pool allocated from the heap early on (which could throw an exception), then using custom allocators to allocate memory for std::string and other objects using that pre-allocated pool. These subsequent allocations would be guaranteed not to throw, because the underlying heap memory has already been allocated from the system and reserved. Doing this would have several benefits:

  1. Make it easier to write functions that are noexcept
  2. Remove lots of error handling code from an application
  3. Improve performance by reducing the number of heap allocations.

Custom allocators can also be used to avoid heap fragmentation, and can allow freeing multiple resources at once from a given (arena) pool. These benefits may justify the effort and added complexity of implementing a custom memory pool and allocator(s).

But I've never been able to get my head around all of the requirements needed to do this. Your article PMR Hacking touches some of this, but does not cover the idea of using a memory pool to avoid subsequent allocation exceptions.

I still have lots of fundamental questions:

  1. Are there any other exceptions that could be thrown when allocating from a custom memory pool?
  2. How do you measure your application to know how big to make the pool?
  3. What if you outgrow the pool and need to make it bigger or add an additional pool?
  4. How do you allocate standard library container elements in the pool and ensure everything 'just works'?
  5. How do you allocate you own classes and heap-allocated objects in the pool?
  6. How do you ensure access to the memory pool is thread-safe? Are there any special rules for dealing with threads and/or thread_local variables when using a memory pool?
  7. How do you deallocate a group of objects from an arena pool all at once?
  8. Do you have to take steps to manage fragmentation within a custom memory pool?
  9. How do you ensure the destructors defer deletion of individual objects that were allocated from an arena pool?
  10. Are static analysis tools smart enough to recognize when an object is being allocated from a pre-allocated memory pool? I.E. will we get false warnings about allocations that could throw?
  11. The standard library offers several allocators and memory resources (i.e. pools) to choose from. Is there a simple guide to what is best for common use cases?
  12. What other strategies are there for writing noexcept-safe functions?

Trying to understand this by reading StackOverflow posts is not easy.

cppal commented 9 months ago

While I've yet to experiment myself with std::expected, I don't see why functions returning it should be noexcept.

  1. Almost anything in C++ might throw. I wouldn't want to have to catch any std::exception just to repackage it. Instead I can consume the returned std::expected via value() and let it throw if it contains a std::unexpected payload. I can then catch those exceptions either in a catch all or do more tailored handling for them and also for any other exceptions I might be interested in handling, thus seamlessly combining the two error mechanisms.
  2. It's generally ill-advised to declare functions as noexcept, other than move constructors and similar constructs (and also destructors which are implicitly noexcept). Read up on the Lakos rule for more on that.
Mike4Online commented 9 months ago

It's generally ill-advised to declare functions as noexcept, other than move constructors and similar constructs (and also destructors which are implicitly noexcept). Read up on the Lakos Rule for more on that.

Thank you, @cppal, for bringing up the Lakos Rule in this thread. I took your advice and read a few sections from The Lakos Rule - Narrow Contracts and noexcept are Inherently Incompatible. I now have a much better sense of both noexcept and the Lakos Rule -- and why the former should be applied in limited circumstances in accordance with the latter. Thanks also to @jslakos for formulating the Lakos Rule and for writing this document.

@fenbf I am still interested in learning more about memory pools an allocators as a way to (1) reduce the number of heap allocations in my applications, (2) reduce the amount of error handling code needed to test for memory allocation failures, and (3) allow numerous objects to be deallocated in bulk -- irrespective of whether such changes would impact a function's suitability to be marked noexcept.

cppal commented 9 months ago

I don't follow. std::expected::value() throws a bad_expected_access exception which contains a copy of the unexpected error object. In which cases do you think that information cannot be examined?

Mike4Online commented 9 months ago

I don't follow. std::expected::value() throws a bad_expected_access exception which contains a copy of the unexpected error object. In which cases do you think that information cannot be examined?

My error. I'll remove or revise my previous comment re: std::expected::value()