Why do I need Option here, I still don’t understand after reading the tutorial?

Tutorial:

https://doc.rust-lang.org/book/ch17-03-oo-design-patterns.html#defining-post-and-creating-a-new-instance-in-the-draft-state

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

Why is Option needed here? By observing the entire source code, it is known that state cannot be empty here, so according to my understanding, there is no need to use Option here.

But when I remove the Option, the following situation occurs.

pub fn request_review(&mut self) {
    // if let Some(t) = self.state.take() {
    //     self.state = Some(t.request_review())
    // }
    self.state = self.state.request_review();
}

Compilation tips:

error[E0507]: cannot move out of `self.state` which is behind a mutable reference
  --> src\lib.rs:26:22
   |
26 |         self.state = self.state.request_review();
   |                      ^^^^^^^^^^ move occurs because `self.state` has type `Box<dyn State>`, which does not implement the `Copy` trait

It prompts that self.state has moved. How can I solve this problem?

There is another problem. When should I use Option and in which scenarios?

Complete source code

main.rs

use oop_exercise::Post;

fn main() {
    let t = "I love China!";
    let mut post = Post::new();
    post.add_content(t);
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!(t, post.content());
}

lib.rs

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(DraftPost {})),
            content: String::new()
        }
    }

    pub fn add_content(&mut self, content: &str) {
        self.content.push_str(content);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

    pub fn request_review(&mut self) {
        if let Some(t) = self.state.take() {
            self.state = Some(t.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(t) = self.state.take() {
            self.state = Some(t.approve())
        }
    }
}

trait State {
    fn content<'a>(&self, _post: &'a Post) -> &'a str {
        ""
    }

    fn request_review(self: Box<Self>) -> Box<dyn State>;

    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct DraftPost {
}

struct PendingReviewPost {
}

struct PublishedPost {
}

impl State for DraftPost {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReviewPost {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

impl State for PendingReviewPost {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(PublishedPost {})
    }
}

impl State for PublishedPost {
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        post.content.as_str()
    }

    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

Well, it is empty for a time...

pub fn request_review(&mut self) {
    if let Some(t) = self.state.take() {
        // was: self.state = Some(t.request_review())
        // `self.state` is now `None`
        let result = t.request_review();
        // It stays empty during this call, which could do anything
        // (The compiler doesn't change its behaviour based on the
        // contents of the function)
        //
        // "doing anything" includes panics!
        self.state = Some(result);
        // Only if we make it here is it no longer empty
    }
}

The panics are the key. Something could happen inbetween moving the value and replacing it that causes the incomplete data structure to be observed, for example during panic unwinding, so it's forbidden.

Here's a longer article on the topic.

You need to solve it with some sort of valid state. Using an Option is one such solution. (Some other approaches are explored in the article.)

4 Likes

Can this function request_review mark that the returned value cannot be empty? Other programming is possible, such as Dart's new null security recently

Basically that would be some sort of effect system ("this function doesn't panic") or a difference in behaviour based on the panic handler ("this program aborts on panics and thus can't unwind"), and no, Rust doesn't support that at this time. It may be considered in the future, at least in some form, but it's not on the immediate horizon.

(I do recommend that article, which also talks about future possibilities such as these.)

1 Like

I wonder if you could clarify what you are asking for there?

When I read about Dart's "Dart's new null security": Sound null safety | Dart I find:

The Dart language now supports sound null safety!

When you opt into null safety, types in your code are non-nullable by default, meaning that variables can’t contain null unless you say they can. With null safety, your runtime null-dereference errors turn into edit-time analysis errors.
With these examples:

// In null-safe Dart, none of these can ever be null.
var i = 42; // Inferred to be an int.
String name = getFileName();
final b = Foo();

Well, "null safety" is the default situation in Rust. Unless you wrap some code in "unsafe". An integer can never be null, it has to be initialised with some integer value before it can be used. Similarly for structs like String. Rust references, borrows always have to point to something. And so on.

Violating these rules will result in errors from the Rust compiler. Or if you are using rust-analyser in your editor/IDE you will see some red squiggles and popup error messages as you edit.

If one really wants a 'nullable' value. In Dart I see:

To indicate that a variable might have the value null , just add ? to its type declaration:

int? aNullableInt = null;

Which in Rust becomes:

    let a_nullable_int: Option<i32> = None;

The question is what does one mean by "null"? An integer can never hold a "null" value. Whatever that "null" means. All the bits available to an integer are used to store valid values of that integer.

If a function is returning "null" does that mean that there was no value available or that there was an error? Or something else like a bug in the program?

All in all I like that Rust gets us to say what we mean, with things like Option and Result, and I have learned as much about Dart today as I ever want to.

2 Likes

My question is, when I do not use Option, can the defined function request_review mark it not to run and return empty?
Similar to Dart as follows:

String testFunc() {
  // return null;  
  // When null is returned, the compilation will report an error
  return "";
}

Thank you very much for your reply, which allowed me to increase my understanding of Rust.

You can return the empty string, which doesn't allocate:

fn test_func() -> String {
    String::new()
}

But if you need the distinction between "no String" and "empty String", that's exactly what Option<String> is for:

fn test_func() -> Option<String> {
    None
}

Think of the None as replacing null, with the added benefit of type safety (you can't accidentally dereference it).

2 Likes

The idea behind Option includes avoiding the need to interpret a null value in a way that is consistent with the “type of interest”/type wrapped by Option.

In the event null really means “”, then go for it. The function type returned will be String.

However, if memory serves, the reason Option was suggested was because you had a state where the value is null (no bueno). In this case, unless you can avoid this state, e.g., by using “” as a temporary placeholder, you need something like Option.

All said, it’s generally bad practice to use dummy values such as “” to encode a null value. The extra effort of having to match against None or Some ends up saving you from having to figure out what is truly meant by “” - is it null or a real empty string?

Go with the flow (if you can) - use the Option!

1 Like

It seems we have a terminology confusion here.

I am used to "" being referred to as an "empty string". It is a perfectly valid string as far as the programming language is concerned, only it has no characters in it.

Being used to languages like C and C++ where strings are referenced by pointers there is an other option. The pointer could hold a value of zero. A NULL pointer. In which case it's not that you have an empty string, you have no string at all. Dereferencing a NULL pointer is often a fatal error in ones program causing crashes.

Then in Javascript a value could hold a string of "" and be perfectly valid. Or it could hold the value null.

I'm pretty sure "" is not a null in Dart's null safety checking either.

Given that your intension is to interpret "" as "no value" or "no string", as when a user just hits SUBMIT on an empty text field in a form they are filling in, then I suggest that you indicate that in your program by returning an "Option".

BUT, your example code you show returning a literal "" as a string which in Rust would look like this:

fn test_func() -> String {
    // return null;
    // When empty string is returned, the compilation will report an error
    return "".to_string();
}

and indicates you wan the compiler to detect return "" and flag it as an error. I don't know how one could create such a compile time error in Rust.

Anyone?

That’ s my understanding/terminology also. The subtle point is that when going from “raw data”, null might actually mean empty string. In other words, null might encode meaning other than “not there/missing”. A better example, sometimes “missing” sales data for a customer that presents as null, actually means $0. All in all, null can encode information other than... null :)) Does that make sense?

Indeed it does. In pretty much every language I have used some special value of a types set of values has often been used to indicate something special other than a valid value of the type. If you see what I mean.

For example in C people often return integers where the positive integers are valid data and one or more negative integer values indicates an error or "no data" or some such.

We see it in C strings where values 1 to 127 are valid ASCII characters, 128 to 255 is unspecified and 0 indicates end of string.

All in all this is pretty daft. One ends up encoding information in a type other than what the type says it conveys. One ends up relying on knowing that, having it documented well, and never forgetting to use it properly. Any reviewer of your code needs to be aware of all that as well because it is not in the code you wrote. Hence it is so easy to make mistakes and introduce bugs in such languages.

Luckily (actually by design by some smart folks who are aware of all this) Rust does not do that. An integer is an integer no matter what value it has, a string is a string even if in contains no characters. Errors and other situations, like "no value" are conveyed by other means, as in Result or Option. In this way you can write what you mean to say and the compiler can check your work.

Of course one could still use such "secret" values in Rust to convey error or no data situations if one really wanted to. I would think that was a very poor design choice.

Exactly.

There are three concrete truths here:

  1. If null means something (e.g., $0), then the parser of the raw data must reflect that accordingly; Option is not an option

  2. Work-arounds including the example you described for ascii, not to mention -1, “” etc., are hacks compared to using something like Option

  3. Not using something like Option to deal with “truly missing” data means you have a partial function... dangerous if not a flawed or incomplete design.

Finally, while an Option impacts the full stack of functions that follow it (a pain point), Rust has done a solid job of increasing the “ease of use” using sugar such as ? to encode the more verbose matching for the functions now operating in the “Option context”.

This one of many examples where Rust “pushes me”/makes it easy “to do the right thing”.

I think we are in agreement. Except I don't understand a couple of things you say there:

How should a parser reflect "no data" or "input error" or whatever other than use Option and/or Result? Or, how come or when is Option not an option?

Also it's not clear to me how using Option impacts the full stack of functions that follow it anymore than returning "" or even a real null pointer? Seems to me those following functions have to handle that situation even if the means by which they know about it is different.

This requires “human” understanding of what it means when a value is missing. In the event the person or process that generated the null value from say an empty string, then it is correct to parse/interpret null -> empty string. If this is correct, by definition anything else is incorrect - “not an option to use Option” because “you know better”.

The difference is once you have chosen to use Option, you’re stuck in the Option context (unwrap, wrap) until the “end-user” receives it as such to determine the appropriate thing to do with None.

In my experience that translation, that final “hand-off” to the end-user involves “a last minute” translation to a Result. Here, I can add more information about why I could not deliver what was expected (if you will).

In some sense, it is similar to propagating an empty string and thus a matter of “pick your poison” for how to deal with input that could be null.

...But, is also different than returning an empty string. There is no unwrap, wrap because there is no additional “context”. Instead you might need a quagmire of if/then statements because we are using a single “channel” (the string type) to encode two different types of information (string and null).

Hmm... I guess there are hundreds of different scenarios. For example:

Often a prompt asks a question and then hitting return, an empty string, is interpreted as whatever the default option is.

Or perhaps the input is not accepted until some choice has been made.

On the other hand when voting in an election for candidates A, B or C, I would hope that not voting is not interpreted as a vote for any one of them.

And so on.

Hopefully in all cases failure of the input interface should not cause the wrong thing to be registered by the interpreter/parser/rest of system.

Seems to me Option and Result and the like can cater to all of those. Use appropriately. The point, for me at least, is to clearly express in your code what the code is supposed to do. Rather than leave the reader wondering if you have forgotten to handle an empty string or error case.

Null -> default value

Null -> None -> try again, or Err(“please try again”)

Null -> Abstain (the enum A, B, C needs to include Abstain)
Note the function is a partial function before expanding the range (aka codomain) to include Abstain.

A lot of what we are discussing is captured in the Monoid pattern. It requires an explicit interpretation of “empty” for your type. One of many benefits of thinking about how my data type would implement a Monoid (if possible). It also requires defining a binary operation for combining values of your type. So much about a type can be revealed by how one might combine them.

For instance, one way to “reveal” how to interpret null might be to combine it with another value. Or, the other way around, what is a value that I can use that does not corrupt combining those values when data is missing (adding 0 to 4, multiplying 1 x 4). This “neutral value”, may or may not be one in same as a chosen default... that’s aok, it just means there is additional/different meaning encoded using neutral vs default.

In that example there is no Null. Hitting return produces an empty string so it's

Empty string -> default value.

Again, no Null involved. Empty strings. Like hitting return when asked for a password.

Ah. I have to drop out of the discussion here. I have very little idea what a "Monoid" might be. Despite following a thousand explanations. Perhaps I have seen some in the wild but "monoid" was never in the vocabulary in my long career.

Precisely. There are ways to precisely describe/specify the relationship between null, neutral (when combining values) and default values of your type.

1 Like

I suspect you likely get-it and leverage the design; just in not so many words.

At the risk of being annoying, the most pithy way I can describe it: Monoids solve a problem we deal with all the time, how to combine two values of our type without having to branch our logic to consider missing values.

That means the ability to generate valid, type-safe results with any collection of values in a single, non-branching statement (i.e., any number of values, including no values at all). Iteration, one way or another, involves a binary operation where the output can serve as input to the next (holds true conceptually for side-effects). Whether the accumulator is the same type as the values feeding the iterator, isn’t relevant and masks the fact that the context created by the body of the iteration, unifies the types of the accumulator with the input (e.g., appending to a Vec from stream of values could be imagined as first converting the input to a Vec with one value, before combining the now, two Vecs before iterating again). That extra layer of imagination (the only place it exists in practice in this case), is a useful indirection that bridges everything to do with iteration... streamlined logic. A single way to describe what we do every day... design Monoids.

I suspect you have a disciplined thought process that replicated the benefits of this perhaps more verbose, but formal description. So, may find less value in it.

The easiest gateway is likely something you already do when deciding a good default value for your type. I often go with one where in a worse case, if left “as-is” it won’t distort how I subsequently combine or otherwise interpret values downstream... no extra branching required, nor having to repeatedly think about the related edge cases.

Convinced? :))

1 Like