Original address: medium.com/dartlang/wh…

Original author:

Published: 8 December 2020 -9 minutes to read

A few weeks ago, we announced Dart NULL Security Beta, an important productivity feature designed to help you avoid null errors. Speaking of null values, on the /r/dart_lang subreddit, a user recently asked, “Why do we still have null values?”

But why do we need a null value? Why not get rid of it altogether? I’m currently playing Rust as well, and it doesn’t have NULL at all. So it seems you can do without it.

I love this question. Why not get rid of NULL altogether? This post is an expanded version of my answer on that post.

The short answer is, yes, it’s perfectly possible to live without NULL, and so is a language like Rust. But programmers do use NULL, so before we take it away, we need to understand why we use it. What does NULL usually do when we use it in languages that have null?

It turns out that NULL is usually used to indicate that there is no value, which is very useful. Some people don’t have middle names. Some people don’t have middle names, some mailing addresses don’t have apartment numbers. Some monsters don’t have any treasure to drop when you kill them.

In such cases, we want a way to say: “This variable may have a value of type X, or it may have no value at all.” So the question is how do we model this?

One option is to say that a variable can contain a value of the expected type, or it can contain the magic value NULL. If we try to use null values, we get a runtime failure. That’s what Dart did before null-safe, what SQL did, what Java did with non-primitive types, and C# did with class types.

But failing at run time is bad. This means that our user experience is buggy. We programmers would rather find these failures before they do. In fact, we’d be happy if we found bugs before we even ran the program. So how do we simulate the absence of a value in a way that the type system can understand? In other words, how can we assign a different static type to a value that “may not exist” than a value that “certainly exists”?

There are two main solutions.

  1. Use an option or type
  2. Use nullable types

Solution 1: Option type

This is what ML and most of the functional languages that derive from ML, including Rust, Scala, and Swift, do. When we know we will definitely have a value, we just need to use the underlying type. If we write int, that means, “There must be an integer here.”

To express a value that may not exist, we wrap the underlying type in an option type. So Option

represents a value that may or may not be an integer. It is like a collection type and can contain zero or one item.

From a type system perspective, there is no directional relationship between int and Option

. Treating these two types as different types means that we can’t accidentally pass an Option

that may not exist to something that expects a true int. We also can’t accidentally try to use Option

as an integer, because it doesn’t support any of these operations. We cannot do arithmetic on Option

, just as we cannot do arithmetic on List

.




To create a value of the option type from the present value of the underlying type (e.g., 3), we can construct the option as Some(3) would. To create an option type without a value, we can write something like None().

In order to use an integer stored in Option

that may not exist, we must first check and see if the value exists. If so, we can extract the integer from the option and use it, just like reading a value from a collection. Languages with option types also usually have good pattern-matching syntax, which gives us an elegant way to check if a value exists and use it if it does.

Solution 2: Nullable types

The alternative (hehe) is what Kotlin, TypeScript, and now Dart do. Nullable types are a special case of union types.

(to the point. This is where the naming gets really messy. Option types — what ML and her friends did above — are a special case of algebraic data types. Another name for algebraic data types is “discriminant union”. However, despite the “union” in the name, “discriminating union” and “union type” are quite different. As Phil Karlton said, there are only two difficulties in computer science: cache invalidation and naming things.)

Similar to the option type approach, we use the underlying type to represent an absolute present value. So int means we definitely have an integer. If we want an integer that might not exist, we use int, right? Nullable types. The small question mark is syntactic sugar, used to write is essentially a joint type, such as int | null.

Just like option types, nullable types do not support the same operations as underlying types. The type system would not let us try to perform arithmetic on a nullable type, because that would not be safe. Similarly, we cannot pass a nullable integer to something that requires an actual integer.

However, the type system is a little more flexible than option types. The type system understands that a union type is a supertype of its branch. In other words, int is int, right? A subtype of. This means that we can pass an integer that absolutely exists to something that expects an integer that might exist, because it’s safe to do so. This is an upper variable, just as we can pass a String to a function that takes Object. Dart simply forbids us to go the other way — from nullable to non-Nullable — because that would be a drop shot, and those would fail.

When we have a nullable type value and we want to see if there is an actual value or null value there, we have to check that value, just as we would naturally do in C or Java.

foo(int? i) {
  if (i != null) {
    print(i + 1);
  }
}
Copy the code

The language then uses process analysis to determine which parts of the program are fenced behind these checks. Analysis determines that code can only be reached if the variable is not null, so in these areas the type system tightens the variable’s type to non-null. So, in this case, it treats I as a typed int in the if statement.

Which solution should a language adopt?

So, when we at the Dart team decide to make the language handle NULL in a safer way, how do we choose between solutions 1 or 2? We can start by looking at our users. How do they want to write code that checks for null values? Pattern matching is one of the primary control flow constructs in functional languages, where users are very comfortable with it. In this style, it is natural to use option types and pattern matching.

In imperative languages derived from C, code like my previous example is a common way to check for null values. Using streaming analysis and nullable typing makes this familiar code work correctly and safely. In fact, in Dart, we found that most of the existing code was already statically null-safe when using the new type system, because the new streaming analysis correctly analyzed the code that had been written.

(This is not surprising in some ways. Most code is already dynamically correct in handling NULL. If it doesn’t, it just keeps falling apart. Most of the work is just making the type system smart enough to see that the code is already correct, so that the user’s attention is drawn to the few things that aren’t.)

So if our goal is to maximize familiarity and user comfort (important criteria for language design), we should follow the path laid out for us by the control flow structure of our language.

Indicates absence and presence

There is a deeper approach to this problem, based on the differences in the representation of option types and nullable types. This difference in presentation forces us to make some key trade-offs that may tilt us in one direction or the other.

Under the first approach, the value of the option type has a different runtime representation than the underlying value. Suppose we select an option type in Dart, you create one, and then upload it to Object.

var optionalInt = Some(3);
Object obj = optionalInt;
print(obj is int); // false
Copy the code

Notice the last line. An Option

value, even if it exists, is not the same thing as a value of the underlying type. Some(3) and 3 are different, distinguishable values.

That’s not how nullable types work.

var nullableInt = 3 as int?;
Object obj = nullableInt;
print(obj is int); // true
Copy the code

Nullable types exist in the static type system, but the runtime representation of a numeric value uses the underlying type. If you have a nullable 3, at run time it’s just the number 3. If you have a nonexistent value of some nullable type, at run time you just have the lonely magic value null.

You can ask if a value is nullable.

print(obj is int?);
Copy the code

But is int? The expression is equivalent to:.

print(obj is int || obj is Null);
Copy the code

Nested options

Since the value of the option type is different from the underlying type, this gives us an important capability. Option types can be nested.

For example, we have some network services that give a resource string when given a request with an integer ID. Some resources do not exist, and the server responds that there is no data for that ID. Since the network is slow, we want to cache the results of requests that have been executed locally.

In Dart before Air Safety, we might use maps like this.

Map<int.String> cache;
Copy the code

So before making a network request for an ID, we use the subscript operator on the cache map to query the ID of the resource. This operator is defined on the Map to return NULL if the key does not exist. But a key can also exist and be associated with a null value. If we run a query and get null, that could mean two things.

  • The key does not exist in the map which means we have not completed the request, so we should ask the server to find the resource.
  • The bond is there, and is associated withnullAssociated with it. This means that we have queried the server, found that the resource does not exist, and stored it in the cache. We should use this result instead of querying the server again.

Because there is only one NULL value in the entire system, we do not have a run-time representation that can distinguish between the two cases. That’s why the Map class has a separate containsKey() method. The API provides a way to distinguish between the two cases.

Now, if Dart were built around option types, the cache would look like this.

Map<int, Option<String>> cache;
Copy the code

The subscript operator returns an optional value.

class Map<K.V> {
  Option<V> operator[](K key) => .... . }Copy the code

In our Map

>, this means that the return type is Option
,>

  • Some(Some(string))It means that the resource actually exists on the server, and we now have it in the cache.
  • Some(None())Indicates that we did ask the server, but the resource does not exist, so we have cached the fact that the resource does not exist.
  • None()Indicates that the cache does not contain this ID at all.

We can distinguish between the last two cases because the option always wraps its underlying value in some extra state. At run time, we can determine how many layers there are and peel them off separately.

Nullable types, because they have no explicit runtime representation, are implicitly flattened. So int? And int?? Is an equivalent type of a type system that has an equivalent set of values at run time. This is why fans of option types describe them as “more expressive” : because option types give you a way to represent a wider variety of values than nullable types.

Substitution of null types

Another way to think about expressiveness is how much effort users have to put into expressing what they really want to express. If users can reach their goals while skipping fewer circles, the language will be more expressive.

One advantage of not having an explicit representation for nullable types is that values can flow more easily from non-nullable types to nullable types. Let’s say you have a function that takes an optional integer argument. For option types, the signature would look like this.

takesMaybeInt(Option<int> optionalInt) {}
Copy the code

To call this function with a known integer, you must first wrap it with an option.

takesMaybeInt(Some(3));
Copy the code

For nullable types, you can pass a value of the underlying type directly, since there is no difference in representation.

takesMaybeInt(3);
Copy the code

You can get this flexibility everywhere in a type system. You can override a method that returns a nullable type to return a nonnullable type. You can pass a List

to someone who wants a List

.
?>

Thus, while nullable types lose the ability to nest and represent many different types of “missing”, in return they make it easier to use the blessed concept of NULL.

Dart’s cavitation

Dart is an imperative language, and people already use if statements to check non-existent values at run time. It’s also an object-oriented language, and we already have a special null value that has its own runtime representation. So solution 2, the nullable type, is the natural answer. It allows our users to write code they are familiar with and take advantage of the way values are represented that already exists at runtime.

For more information about Nullability in Dart, see the Where to Learn More section of the Dart NULL security documentation.


Translation via www.DeepL.com/Translator (free version)