This thread has generated a lot of interesting discussion, and a lot
of ideas for non-null pointers in Go. Much of the discussion has been
about whether non-null pointers are a good idea. I'm going to assume
that they are, and try to summarize the designs that have been
proposed or hinted at. With the design space laid out, we can see if
there is any portion of the space I neglected, and if any of the
possible designs can work for Go.
The topic of nullable types in general has also appeared in this
thread, but it's not relevant here: I'm only discussing the issues
with non-null pointers.
In this message, I'm going to consider only the most ambitious goal,
which I'll call static null safety: making it impossible to
dereference a null pointer at runtime. As far as I know, everyone
agrees that null pointers have their uses. All proposals involve
making non-null pointers a distinct type from nullable pointers.
Dereferencing can happen only on non-null pointers.
Aside from the notation, which I'll omit discussing, two issues arise:
conversions and initialization.
Conversions
------------------
There must be a way to convert a nullable pointer to a non-null one,
so that it can be dereferenced.
This conversion could be implicit. In other words, there is no mark in
the source--you just do the assignment. This would be accompanied by a
runtime check. This solution is equivalent to having the compiler
insert implicit null checks on each dereference, which is
essentially what we have now, except that the hardware does the check
for free.
Conversion could be explicit, looking like other type conversions in
Go, and would involve a runtime check. The advantages of this idea
over the current language are that we have clearly marked places in
the code that we can examine for errors, and that we may reduce the
total number of required checks by judicious placement of the
conversions. The disadvantages are code clutter, and the need to
repeat checks that we already do as a matter of course. To see the
latter point, consider this method, from list.go:
func (l *List) iterate(c chan<- interface{}) {
for e := l.front; e != nil; e = e.next {
c <- e.Value
}
close(c);
}
Here e refers to a *Element, which must be nullable to mark the end of
the list. The expressions e.Value and e.next involve dereferencing a
nullable pointer, and so would require conversion and a runtime check.
But we already know that e cannot be null because of the test in the
for statement. So besides the clutter, we would be doing two
additional runtime checks on each iteration.
This leads us to the third possibility for conversion: compiler
deduction. Here, the compiler can deduce from the test that e is not
null at the time e.Value and e.next are evaluated, so no explicit
conversions and no additional checks are necessary.
There are some problems with the deduction approach:
1. To keep the compiler simple and fast, the Go team has chosen not to
perform kinds of analysis necessary to make these deductions.
2. The analysis is straightforward for local variables not captured by
closures, but is complicated by closures. I don't know exactly how
complicated it is, but given #1 above, it might not require much
complexity for the Go folks to balk.
3. For expressions other than local variables, the analysis is not
doable, because of concurrency. E.g. consider
if e.next != nil { return e.next.next }
It would be wrong to allow this code to compile, because another
goroutine might modify e.next between the check and the dereference.
4. The analysis would of necessity be conservative, and one might need
or greatly prefer to write code that was correct but could not be
proved so by the compiler.
I think that these problems can be partially gotten around as follows:
say that expr is an expression of nullable pointer type. Then one can
write:
if p := expr; p != nil { /* dereference p safely here */ }
The compiler would still have to do some analysis, but only enough to
realize that p is fresh and local, and that the test suffices to prove
p non-null. The disadvantages of this are that it clutters the code,
and it still doesn't help in cases like the above iterate method where
the test is in a for loop.
Initialization
-----------------
Now I'd like to talk about the problem of initializing non-null
pointers. Go supports uninitialized variables, makes pervasive use of
the notion of a "zero value" for a type, and has no formal notion of
constructor. These interact quite severely with the idea of non-null
pointers. The fundamental problem, of course, is what to do with a
variable of non-null pointer type between the time it is created and
the time it is first assigned to. There are basically two options,
which can be mixed and matched:
1. Disallow creation without initialization
2. Provide a "zero value" that is something other than nil.
Let's consider #2 first. As an example, one idea I've seen is to have
the zero value for *T be new(T). There are three disadvantages to this
approach:
1. It complicates and slows down memory allocation. Consider a struct
which contains nested structs to several levels. In current Go, this
can be initialized with a very fast bulk setting of the memory to zero
(i.e., calloc()). Initializing non-null pointers to values other than
zero will be slow. (I believe someone suggested that you could
represent the zero value with a zero bit pattern, but short of
checking on every access, I don't see how this could be implemented).
2. It could require a lot of memory allocation. This problem can be
handled by using a single zero value for each type; all pointers would
be initialized to the same object.
3. Instead of crashing due to dereferencing a null pointer, a program
continues running with an unexpected value. Since it is almost always
better to crash than to run with bad data, this is a severe
disadvantage of the "zero value other than nil" approach. Since this
problem will come up a few times in what follows, I'm going to name it
ZVINAE (Zero Value is Not An Error).
Now to the first idea, disallowing creation without initialization.
This makes the most sense for scalar local and global variables; it is
not too much of a burden to write var x = new(T). But how do we
handle fields in structs? Some languages (e.g. Spec#) require that non-
null pointers be initialized before the constructor ends (earlier,
actually); but Go has no constructors.
One possibility is to leave the field uninitialized, but require
assignment to them near (e.g. in the same block as) the new()
expression, or variable declaration. The compiler would enforce that
the variable is assigned to before being read. I don't believe this is
statically analyzable in practice. Whether conservative approximations
will put an unacceptable burden on programmers and execution time I
don't know, but I would guess not. Consider this code, also from
list.go:
// Init initializes or clears a List.
func (l *List) Init() *List {
l.front = nil;
l.back = nil;
l.len = 0;
l.id = new(byte);
return l;
}
// New returns an initialized list.
func New() *List { return new(List).Init() }
If List contained non-null pointers, I don't think New() could be
written as it is. (Perhaps a compiler could analyze a local, simple
function like Init(), but what if the function were in another package
and/or had complex control flow?)
Another solution to the problem of initializing structs containing non-
null pointer fields is to disallow the use of new, and require the
literal syntax. For example, a struct S with field f of type non-null
pointer to T could be initialized with S{f: new(T)}. We still have the
problem of circular references; for those, you'd have to use a dummy
object temporarily. However, what if the circular reference is to a
value of the same type? Consider this code, from ring.go:
type Ring struct {
next, prev *Ring;
Value interface{};
}
The next and prev fields are candidates for non-nullity, since they
are, once initialized, never null. But how do we initialize them?
Since we are disallowing new(Ring), we must write Ring{next: ...,
prev: ...}. Since next is of type *Ring, we have to do something like
Ring{next: Ring{...}, prev: Ring{..}}. You can see where this is
going. It seems that the only way to break the regress is to have a
special value magically given to us by the compiler, with each
internal pointer of the same type initialized to point to the special
value itself.
Of course, use of these dummy objects to break circularity, whatever
their provenance, would be a case of ZVINAE.
There is another, more prosaic problem with the idea of requiring
initialization for structs with non-null pointer fields. Consider the
case where the designer of a large struct S wants to allow immediate
values of S, but requires that values of S be initialized. In the
current language, you'd provide a method
func (s *S) Init() { ...}
and ask users to call it. But if we have to intialize an S at
declaration time, we'll have to write something in S{...} that will
likely get thrown away. We'll waste time writing it, and the machine
will waste time creating it.
Finally, there are two problems with arrays. First How do we handle
initialization of an array of non-null pointers?
1. Disallow them, as in Spec#. It's not clear what affect this would
have on the use of non-null pointers. Say that List.New() returns a
non-null List pointer (as of course it should). Now I want an array of
*Lists (for my own hashtable, perhaps). What can I do? I guess I'll
make an array of nullable pointers to *Lists. Now I have null pointers
AND double indirection.
2. Allow them to be created uninitialized, but require each element to
be initialized before use. I don't believe this is statically
checkable in a reasonable way.
3. Require an initial value to be provided. E.g. something like
var a [100]*T(new(T)).
where each element of a points to the same value. This is the ZVINAE
problem, only a little better because the programmer can choose the
initial value for each array. Still, it's unlikely that the programmer
wants 100 pointers to the same object, so the initial contents of the
array are probably wrong.
4. Provide an initialization function, E.g.
var a [100]*T(func(i int) *T { return &T{x: 1, y: 2 })
The advantage here is that each element is initialized to something
that has a chance of being what the programmer desired. The
disadvantage is the cumbersomeness of the code. And how do we handle a
case where a value depends on a previous array element (but not on the
index i)? There's probably a clever way to do it, but it would not be
as simple as just writing a loop.
The second array initialization problem concerns an array of structs,
where the struct type contains non-null pointers:
type S struct { f *T } // assume *T is non-null
var a [100]S
The function approach has the problem that we'd have to return an S
from the function, and S may be large. If instead the function takes a
pointer to an S to populate, then it will be able to examine the
uninitialized non-null pointer fields of S, which we cannot allow. It
seems the only reasonable solution here is to disallow such arrays,
just as C++ disallows arrays of a class without a no-arg constructor.
Summary
--------------
I've looked at possible designs for static null safety in Go -- the
idea that null pointer dereferences are impossible in a successfully
compiled Go program. The idea's two problem areas are conversions from
nullable to non-null types, and initialization of non-null values. It
appears as though conversions would require an amount of compiler
analysis and/or code clutter that is antithetical to the spirit of the
language. More severely, any general solution to the problem of
initializing non-null pointers when they appear as struct fields would
be cumbersome, would require a magic language-provided dummy value,
and would introduce ZVINAE.
A few final words. I am not passing judgment on the quality of the Go
language, the wisdom of the Go design team, or the value of static
null safety in programming languages. I am simply trying to
dispassionately examine the possibilities.
We can further this discussion by:
1. Pointing out technical flaws in my analysis. For example, perhaps
static analysis of assignment to struct fields is easier than I think.
2. Finding a point in the design space that I missed, and that is
congruent with Go. As a counterexample, consider a proposal that
requires adding constructors to the language. The Go team knew about
constructors, and decided not to add them. It is not hard to guess
why: they realized that two features they already had, package
encapsulation and functions, already provided them with everything
they felt was valuable about constructors, and without the added
complexity. You will not convince them to add static null safety to Go
by requiring that they first add constructors. Any proposal would have
to make minimal changes to the language.
3. Giving up the strong position of static null safety for a weaker
view, one that allows for the possibility of runtime dereferencing of
null in some circumstances.
4. Propose and implement a point in the design space to demonstrate
that issues like compilation slowdown and code clutter are not as bad
as I make them out to be.