Skip to content

Commit 133e8b4

Browse files
committed
Minor changes
1 parent 4cd8362 commit 133e8b4

File tree

1 file changed

+44
-13
lines changed

1 file changed

+44
-13
lines changed

lectures/optional.md

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
- [Summary](#summary)
1818

1919

20-
When working with modern C++, we often need tools to handle optional values. These are useful in many situations, like when returning from a function that might fail during execution. Since C++17 we have a class `std::optional` that can be used in such situations. And since C++23 we're also getting `std::expected`. So let's chat about what these types are, when to use them and what to remember when using them to make sure we're not sacrificing any performance.
20+
When working with modern C++, we often need tools to handle optional values. These are useful in many situations, like when returning from a function that might fail during execution. Since C++17 we have a templated class `std::optional` that can be used in such situations. And since C++23 we're also getting `std::expected`. So let's chat about what these types are, when to use them and what to remember when using them to make sure we're not sacrificing any performance.
2121

2222
<!-- Intro -->
2323

2424
## Use `std::optional` to represent optional class fields
25+
2526
As a a first tiny example, imagine that we want to implement a game character and we have some items that they can hold in either hand (we'll for now assume that the items are of the same pre-defined type for simplicity but could of course extend this example with a class template):
27+
2628
```cpp
2729
struct Character {
2830
Item left_hand_item;
@@ -33,6 +35,7 @@ struct Character {
3335
The character, however, might hold nothing in their hands too, so how do we model this?
3436
3537
As a naïve solution, we could of course just add two additional boolean values `has_item_in_left_hand` and `has_item_in_right_hand` respectively:
38+
3639
```cpp
3740
struct Character {
3841
Item left_hand_item;
@@ -42,9 +45,11 @@ struct Character {
4245
bool has_item_in_right_hand;
4346
};
4447
```
48+
4549
This is not a great solution as we would then need to keep these variables in sync and I, for one, do not trust myself with such an important task, especially if I can avoid it. So, speaking of avoiding this, can we somehow bake this information into the stored item types directly?
4650

4751
We _could_ just replace the items with pointers and if there is a `nullptr` stored in either of those it would mean that the character holds no item in the corresponding hand. But this has certain drawbacks as it changes the semantics of these variables.
52+
4853
```cpp
4954
// 😱 Who owns the items?
5055
struct Character {
@@ -58,6 +63,7 @@ Before, our `Character` object had value semantics and now it follows pointer se
5863
This is not great. The simple decision of allowing the character to have no objects in their hands forces us to actively think about memory, complicating the implementation and forcing unrelated design considerations upon us.
5964
6065
One way to avoid this issue is to store a `std::optional<Item>` in each hand of the character instead:
66+
6167
```cpp
6268
struct Character {
6369
std::optional<Item> left_hand_item;
@@ -70,7 +76,9 @@ Now it is clear just by looking at this tiny code snippet that neither item is r
7076
Before we talk about how to use `std:::optional`, I'd like to first talk a bit about another important use-case for it - **error handling**.
7177

7278
## Use `std::optional` to return from functions that might fail
79+
7380
Let's say we have a function `GetAnswerFromLlm` that, getting a question, is supposed to answer all of our questions using some large language model.
81+
7482
```cpp
7583
#include <string>
7684

@@ -80,15 +88,19 @@ std::string GetAnswerFromLlm(const std::string& question);
8088
This is a simple interface that serves its purpose in most situations: we ask it things and get some `std::string` answers, sometimes of questionable quality. But what happens if something goes wrong within this function? What if it _cannot_ answer our question? What should this function return so that we know that an error has occurred.
8189
8290
Largely speaking there are two schools of thought here:
91+
8392
- It can throw an **exception** to indicate that some error has occurred
8493
- It can return a special value to indicate a failure
94+
- TODO: add a third option where it sets some global error state
8595
8696
### Why not throw an exception
87-
We'll have to briefly talk about the first option here if only to explain why we're not going to talk about in-depth. And I can already see people with pitchforks coming for me so do note that this is a highly-debated topic with even thoughts of [re-imagining exceptions altogether](https://www.youtube.com/watch?v=ARYP83yNAWk).
8897
89-
Anyway. Exceptions. Generally, at any point in our program we can `throw` an exception. It then is handled in a separate execution path, invisible to the user and can be caught at any point in the program upstream from the place where the exception was thrown by value or by reference. Yes, exceptions are polymorphic and use [runtime polymorphism](inheritance.md#using-virtual-for-interface-inheritance-and-proper-polymorphism), which is one of the issues people have with them.
98+
We'll have to briefly talk about the first option here if only to explain why we're not going to talk about it in-depth. And I can already see people with pitchforks coming for me so do note that this is a highly-debated topic with even thoughts of [re-imagining exceptions altogether](https://www.youtube.com/watch?v=ARYP83yNAWk).
99+
100+
Anyway. Exceptions. Generally, at any point in our program we can `throw` an exception. It then is handled in a separate execution path, invisible to the user and can be caught by value or by reference at any point in the program upstream from the place where the exception was originally thrown. Yes, exceptions are polymorphic and use [runtime polymorphism](inheritance.md#using-virtual-for-interface-inheritance-and-proper-polymorphism), which is one of the issues people have with them.
90101
91102
In our case, if, say, the network would be down and our LLM of choice would be unreachable, the `GetAnswerFromLlm` would throw an exception, say a `std::runtime_error`:
103+
92104
```cpp
93105
#include <string>
94106
@@ -102,6 +114,7 @@ std::string GetAnswerFromLlm(const std::string& question) {
102114
```
103115

104116
On the calling side, we would need to "catch" this exception using the `try`-`catch` blocks. Generally, if using exceptions for reporting errors, we wrap the code we want to execute into a `try` block that is followed by a `catch` block that handles all of our potential errors.
117+
105118
```cpp
106119
int main() {
107120
try {
@@ -114,37 +127,47 @@ int main() {
114127
}
115128
}
116129
```
130+
117131
I will not talk too much about exceptions, mostly because in around a decade of using C++ professionally I very rarely worked in code bases that use exceptions. Many code bases, especially those that contain safety-critical code, ban exceptions altogether due to the fact that there is, strictly speaking, no way to guarantee how long it takes to process an exception once one is thrown because of their dynamic implementation.
132+
<!-- TODO: link Stack Overflow questionnaire about using exceptions -->
118133

119134
Furthermore, there is another thing I don't really like about them. They create a hidden logic path that can be hard to trace when reading the code.
120135
You see, the `catch` block that catches an exception can be in _any_ calling function and it will catch a matching exception that is thrown at any depth of the call stack.
121136

122-
This typically means that we have to become very rigorous about what function throws which exceptions when and, in some cases, the only way to know this is by relying on a documentation of a function which, in many cases, does not fully exist or is not up to date. I firmly believe that the statement `catch (...)` is singlehandedly responsible for many errors that we've all encountered.
123137

124138
<img src="images/error.png.webp" alt="Video Thumbnail" align="right" width=50% style="margin: 0.5rem">
125139

140+
This typically means that we have to become very rigorous about what function throws which exceptions when and, in some cases, the only way to know this is by relying on a documentation of a function which, in many cases, does not fully exist or is not up to date. I firmly believe that the statement `catch (...)` is singlehandedly responsible for many errors of the style of "oops, something happened" that we've all encountered.
141+
126142
To be a bit more concrete, just imagine that the `LlmHandle::GetAnswer` function throws some other exception, say `std::logic_error` that we don't expect - this would lead us to showing such a `"Something happened"` message, which is not super useful to the user of our code.
143+
<!-- TODO: add an example of this -->
127144

128145
### Avoid the hidden error path
129-
All of these issues prompted people to think out of the box to avoid using exceptions but still to allow them to know that something went wrong during the execution of their function.
146+
147+
All of these issues prompted people to think out of the box to avoid using exceptions. And that while still having a way to know that something went wrong during the execution of some code.
130148

131149
In the olden days (before C++17), there were only three options.
132-
1. The first one was to return a special value from the function. When the user receives this function they know that an error has occurred:
150+
151+
1. The first one was to return a special value from the function. When the user receives this value they know that an error has occurred:
152+
133153
```cpp
134154
#include <string>
135155

136-
// 😱 Not a great idea nowadays.
156+
// 😱 Assumes empty string to indicate error. Not a great idea nowadays.
137157
std::string GetAnswerFromLlm(const std::string& question, std::string& answer) {
138158
const auto llm_handle = GetLlmHandle();
139159
if (!llm_handle) { return {}; }
140160
return llm_handle->GetAnswer(question);
141161
}
142162
```
143-
This option is not ideal because it is hard to define an appropriate "failure" value to return from most functions. For example, an empty string sounds like a good option for such a value, but then the LLM response to a query "Read this text, answer with empty string when done" would overlap with such a default value. Not great, right? We can extend the same logic of course for any string we would designate as the "failure value"
144-
2. Another historic option is to return an error code from the function, which required passing any values that the function had to change as a non-const reference or pointer:
163+
164+
This option is not ideal because it is hard to define an appropriate "failure" value to return from most functions. For example, an empty string sounds like a good option for such a value, but then the LLM response to a query "Read this text, do not answer anything when done" would overlap with such a default value. Not great, right? We can extend the same logic of course for any string we would designate as the "failure value".
165+
2. Another option is to return an error code from the function, which required passing any values that the function had to change as a non-const reference or pointer:
166+
145167
```cpp
146168
#include <string>
147169

170+
// Returns a status code rather than the value we want.
148171
// 😱 Not a great idea nowadays.
149172
int GetAnswerFromLlm(const std::string& question, std::string& answer) {
150173
const auto llm_handle = GetLlmHandle();
@@ -153,9 +176,11 @@ In the olden days (before C++17), there were only three options.
153176
return 0;
154177
}
155178
```
179+
156180
This options is also not great. I would argue that not being able to have pure functions that get only const inputs and return a single output makes the code a lot less readable. Furthermore, modern compilers are very good at optimizing the returned value and sometimes the function that constructs this value altogether which might be a bit harder if we pass a reference to some value stored elsewhere. Although I don't know enough about the magic that the compilers do under the hood to be 100% about this second reason, so if you happen to know more - tell me!
157181
<!-- In the comments below this video -->
158182
3. An arguably even worse but still sometimes used method (OpenGL, anyone?) is to set some global error variable if an error has occurred and explore its value after every call to see if something bad has actually happened.
183+
159184
```cpp
160185
#include <string>
161186

@@ -173,11 +198,13 @@ In the olden days (before C++17), there were only three options.
173198
return llm_handle->GetAnswer(question);
174199
}
175200
```
176-
I believe I don't have to go into many details as to why his is not an ideal way to deal with errors: it is even less readable and more error prone than the previous method. We even have to use a mutable global variable! Good luck testing this code, especially when running a number of tests in parallel.
177201

178-
But I would not be telling you all of this if there were no better way. This is where `std::optional` comes to the rescue. Instead of all of the horrible things we've just discussed, we can return a `std::optional<std::string>` instead of just returning a `std::string`:
202+
I believe I don't have to go into many details as to why his is not an ideal way to deal with errors: it is even less readable and more error prone than the previous method. We even have to use a mutable global variable! Also, good luck [testing](googletest.md) this code, especially when running a number of tests in parallel.
203+
204+
But I would not be telling you all of this if there were no better way, would I? This is where `std::optional` comes to the rescue. Instead of all of the horrible things we've just discussed, we can return a `std::optional<std::string>` instead of just returning a `std::string`:
179205

180206
`llm.hpp`
207+
181208
```cpp
182209
#include <optional>
183210
#include <string>
@@ -188,14 +215,17 @@ std::optional<std::string> GetAnswerFromLlm(const std::string& question) {
188215
return llm_handle->GetAnswer(question);
189216
}
190217
```
218+
191219
Now it is super clear when reading this function that it might fail because it only _optionally_ returns a string. It also forces us to deal with any potential error happening inside of this function when we call it because the _type_ or the value we get forces us to do it. No hidden error path!
192220
193221
Note also, that the code of the function itself stayed _exactly_ the same as in the case where we would indicate an error by returning an empty string, just the return type is different!
194222
195223
## How to work with `std::optional`
224+
196225
So let's see how we could work with such a function! For this we'll call it a couple of times with various prompts and process the results that we're getting:
197226
198227
`main.cpp`
228+
199229
```cpp
200230
#include "llm.hpp"
201231
@@ -236,12 +266,13 @@ Now if we have a network outage, we can return an error that tells us about this
236266
## How are they implemented and their performance implications
237267
Largely speaking, both `std::optional` and `std::expected` are both implemented as a `union` in C++, meaning that the expected and unexpected values are stored _in the same underlying memory_ with helper functions allowing us to query which one is actually stored there.
238268
239-
This means that if the unexpected type is smaller than the expected type, there is no memory overhead. This leads us to the first performance consideration: **we should not use large types for the _unexpected_ type in `std::expected`**. Otherwise, we might be wasting a lot of memory:
269+
This means that if the unexpected type has a smaller memory footprint than the expected type, then there is no memory overhead. This leads us to the first performance consideration: **we should not use large types for the _unexpected_ type in `std::expected`**. Otherwise, we might be wasting a lot of memory:
240270
```cpp
241271
// 😱 Not a great idea.
242272
std::expected<int, HugeType> SomeFunction();
243273
```
244-
Here, instead of returning an tiny `int` object we will now always return an object that takes the same amount of memory as `HugeType`. As allocating memory is work, this will also most probably be slower than returning tiny integer numbers.
274+
Here, instead of returning a tiny `int` object we will now always return an object that takes the same amount of memory as `HugeType`. As allocating memory is work, this will also most probably be slower than returning tiny integer numbers.
275+
<!-- TODO: illustrate the above -->
245276

246277
The good news here is that there is not much we can do wrong with `std::optional` on this front as it holds a small `std::nullopt` type if it does not hold the expected return type.
247278

0 commit comments

Comments
 (0)