NullPointerException vs Panic on call to unwrap

Hey everyone!

I've started my programming journey 6 years ago with Java and I have to say that NullPointerExceptions are still my most hated programming concept of all time. When I first heard about Rust 4 years ago and how it proclaims to be NullPointer free I was immediately hooked and Rust quickly became my favourite programming language. Meanwhile I worked with both Java and Rust on a personal and a professional level for quite some time, however there is still something I still can't get behind, which is quite embarassing to me but I want to ask nonetheless:

Why is it any better that Rust panics, when calling .unwrap() on a None value, compared to Java throwing a NullPointerException when calling a method on a NullPointer?

For example if we had this program in Java:

public class Main {
    public static void main(String[] args) {
        String nullpointer = null;
        System.out.println(nullpointer.length());
    }
}

We get this error message:

Exception in thread "main" java.lang.NullPointerException
        at Main.main(Main.java:4)

The same program in Rust:

fn main() {
    let null_pointer: Option<String> = None;

    println!("{}", null_pointer.unwrap().len());
}

Panics with this message:

thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', src/main.rs:4:36

From my point of view this isn't any better compared to the NullPointerException, despite it not being an access to a NullPointer per se.
I know that .unwrap() abstracts away the case analysis of the Option<T> enum but the behaviour, in that it crashes (or panics) the program, is still the same.

The difference is that in Java, every value could be null, and the type system doesn’t help you in the common cases of values that will never be null (or rather, are never supposed to be null), whereas in Rust, the choice of using Option<T> over T means there’s an explicit “marker” in your code that the value is “nullable”. The same kind of difference (explicit in Rust vs. implicit in Java) goes for the use-sites, too then: While method-calls in Java all implicitly do a “null-check + conditionally throw an exception” step, in Rust with Option, it’s explicit. Common use-cases of Option involve properly handling null None without panicking (e.g. by returning None or an Err(…) from the surrounding function, or properly handling those None values that didn’t indicate any error at all in the first place, e.g. representing the end of a linked list, etc.), and the type forces you to either do that or explicitly opt into the Java-esque behavior by using unwrap.

When using unwrap, there’s usually an obligation to double-check your logic for whether the call to unwrap should never be able to fail. Adding an explanatory comment in nontrivial cases can be useful, too. There’s different best practices around this, though. Some people only use unwrap while prototyping code, and replace all instance of unwrap either with a proper error handling logic, or with a properly explained .expect that should never fail (either it actually never fails, or it only fails in documented panic cases of the method/function being implemented). There’s even an opt-in clippy lint to warn about all use cases of unwrap, which can be used to facilitate the approach of only using unwrap in prototyping and replacing all uses later.

25 Likes

The big difference for me is that you explicitly chose to blow up in the face of None, instead of it happening silently.

In "real" code I'll almost never use unwrap(), so I might use unwrap_or_else() to use a fallback value, or context("xxx wasn't found")? to return an error to the caller.

6 Likes

.unwrap() usually isn't the norm, the way dereferencing a potentially-null reference in Java is. Unwrap is appropriate in two contexts:

  • When there is provably no way for the value to be None/Err, such as when you're using a general-purpose None-able or fallible call with values that structurally cannot produce those outcomes, or

  • When you want to propagate a panic, such as when unwrapping the result of trying to lock a mutex which may be poisoned.

If neither of those cases hold, then it's generally a better idea to expressly handle the None case, or to restructure the program so that the value is not optional, as appropriate. For your examples, I'd go with the latter, and eliminate unwrapping that way:

fn main() {
    let s = "Hello, world!"; // s: &'static str, not Option<String>

    println!("{}", s.len());
}

If the value reasonably is optional, then handle it, rather than calling unwrap:

fn main() {
    let null_pointer: Option<String> = None;

    if let Some(s) = null_pointer {
        println!("{}", s.len());
    } // optionally an else case…
}

Rust won't save you from errors that arise from assuming a nillable value is always non-nil. It makes it clearer when you've done that (to the point that you can lint on calls to .unwrap() and find every place you've done so), but if you want to do it, Rust will let you and fail at runtime, just like Java would.

3 Likes

Ok first of all thank you all for your reponses, this really cleared things up for me.
Now, what I take away from your comments is that Rust does not prevent NullPointers per se, but rather heavily incentivices us programmers to write code that handles cases in which a value could be a null pointer, is that thought correct?

1 Like

Broadly. I would say that it encourages you to either handle the case, or make a decision not to handle it, or to eliminate that case entirely, and provides tools for doing any of those three things that are clear and explicit.

1 Like

In the context of Options, I would say it doesn't just heavily incentivise handling the None case, it forces you to handle it. The compiler won't allow you to not handle it and pretend that it's not an Option. unwrap shouldn't be the primary way you do it, but unwrapping an Option is still explicitly handling it, saying "panic if it's None".

9 Likes

I see, thank you all for your insight!

Yes, that seems like a reasonable interpretation. Regarding whether or not Rust is “null pointer free”: Null pointers are a useful optimization / implementation details; I love the way that Rust approaches them with the Option enum, as a special case of the more general concept of “enums” (aka “sum types”, more generally “algebraic data types”), and null pointers merely become an optimization in terms of the way that Option<SomePointerType> is represented in memory.

In this sense, (safe) Rust “has null pointers”, since None of type Option<SomePointerType> will be implemented as a “null” value, but also it kind-of “doesn’t have null-pointers”, because this so-called “niche-filling optimization [1]” is really just an optimization, and pointer types such as Box<T>, &T, &mut T, etc don’t inherently have a “null pointer” value. (Of course, unsafe Rust has actual null pointers, too, but unsafe Rust is its own topic.)


  1. note that the actual set of niche-filling optimizations the compiler can currently do is way more general than just the special cases that are guaranteed to happen by the language specification ↩︎

2 Likes

I would like to add that in Rust, a value of type &T isn't just guaranteed to "not be null", but also guaranteed to refer to an initialized/valid (and properly aligned) T.

Thus, unless you use Option or similar, a reference can never be "wrong". (And if you use Option, you add None to the possible/valid values.)

2 Likes

Caveat: in the specific case of Option<&T> where the null pointer optimization is guaranteed, it's not really just an optimization, it's a property that can be relied on even in zero-optimization builds.

In a way, this is similar to e.g. C++ guaranteed copy/move elision, where copy/move elision is actually observable in C++, so must be semantically provided for rather than just an optimization.

Guaranteed tail call optimization (which Rust doesn't have, I don't know about C/C++) is similar again, as in a debug build TCO is still required to avoid improperly blowing the stack (though resource exhaustion is special w.r.t. observability and determinism guarantees...)

So in one sense, yes, the null pointer optimization (along with GCE, TCO, etc) is just an optimization; if you don't rely on it happening, then the program has the same semantics (modulo resource exhaustion) both with and without. But it's also more than just an optimization, because it's guaranteed to happen, and as such code can (and in practice does) get written that relies on it happening.

(The canonical example is using Option<&T> in FFI.)

2 Likes

I wouldn't say that. Pointers or not, you can just choose never to use Option, and then you would truly, really be free from any burden related to nullability.

Furthermore, if you still choose to use Option sometimes (which is a reasonable and practically inevitable thing to do), then you still can't accidentally and implicitly cause the code to blow up due to nullability.

No language can prevent all kinds of logic errors, so I think claiming that "Rust really isn't better than Java, it just has a different syntax for some bad things" is dishonest, and expecting that it be able to guess the programmer's intention is not reasonable.

6 Likes