-
Notifications
You must be signed in to change notification settings - Fork 17.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
proposal: "Filled types": a mechanism to guarantee types are not nil at compile time #33078
Comments
Maybe it will just be easier to make any pointer type implement an Option interface (Rust, Java, Swift, Scala, etc) and encourage people to use that instead of raw pointers, since the later can't realistically be disabled or it will break every program in existence. Seems the world has more or less settled on that type or similar for null safety. |
You mean |
@urandom @nebiros Thanks for your comments.
That's a good point of view, but if we allow the possibility of using the "nillable types" (I'm calling them this way instead of "pointer types" to leave it clear that we are not talking only about pointers) both through the That's one of the reasons why I ended up adding a type modifier. Other reasons are related to adhering to the Go philosophy as much as possible (simplicity, readability, etc.) For example, here are three examples:
func handleStockChangeEvent(product filled *Product, event filled *StockEvent) filled *Product {
return &Product{
ID: product.ID,
Stock: product.Stock + event.StockIncrement,
}
}
func main() {
// ...
newProduct := handleStockChangeEvent(product, event)
fmt.Println("New product stock:", newProduct.Stock
}
// We need to change the signature so that we return the new Product and an error
// as, now, the parameters could be nil and, in that case, the function could not proceed.
func handleStockChangeEvent(product *Product, event *StockEvent) (*Product, error) {
if product == nil {
return nil, errors.New("product parameter is nil")
}
if event == nil {
return nil, errors.New("event parameter is nil")
}
return &Product{
ID: product.ID,
Stock: product.Stock + event.StockIncrement,
}
}
func main() {
// ...
newProduct, err := handleStockChangeEvent(product, event)
if err != nil {
log.Fatal(err)
}
fmt.Println("New product stock:", newProduct.Stock
}
// We would still need to return a possible error, as the parameters can still be "empty".
// However, for the shake of showing another option, let's assume that our program
// doesn't care about the "empty" parameters case
func handleStockChangeEvent(product optional *Product, event optional *StockEvent) optional *Product {
// Lets assume the "Java" interface for optionals.
// We could also think about the `if let` Swift version.
if product.empty() || event.empty() {
return optional(nil)
}
return &Product{
// However, this `.get()` function can still panic if you didn't check for "nil" before
ID: product.get().ID,
Stock: product.get().Stock + event.get().StockIncrement,
}
}
func main() {
// ...
newProduct := handleStockChangeEvent(product, event)
if newProduct.empty() {
return
}
fmt.Println("New product stock:", newProduct.get().Stock
} For me, the example A) is the one that provides more value: · We have full "compiler assistance": You are completely sure that the parameters "are there" and that it is impossible to have a panic just by inspecting their values. Finally, regarding:
I would say that something refreshing about Go is that it didn't take for granted what the world was settled on (like object orientation, inheritance, exceptions, etc.). It rethought it and created something simpler (or removed it :-) ) In conclusion: There are several ways of overcoming the "nil dereference errors", each one with its strengths and weaknesses. We need to find the one that fits best in Go, not only with its philosophy but with its current state of the art: the mindset of all the developers using it for years, backward compatibility with current programs, orthogonality with existing and planned functionality, etc. |
I have made some updates to the proposal:
Now the idea is to get some feedback, either positive or negative, especially to find out points that do not fit well in Go. |
Zero values currently exist in quite a few places in the language. I think you need to address how filled types (which have no zero value) behave in each case. For example, Similarly, slices can be resliced, exposing zero elements: var x int
var slice []filled*int = []filled*int{&x, &x, &x}
for i := 0; i < 300; i++ {
slice = append(slice, &x)
}
// at this point, cap(slice) > len(slice)
// (This is not guaranteed by the language, but is very likely)
slice = slice[:cap(slice)]
slice[len(slice)-1] // zero value! Without allowing capacity to grow larger than length, the performance of Similarly, when reading from a closed The same issues apply whenever a filled type is used as a Do you intend to allow implicit conversions from Lastly I think the method sets for filled pointer types might lead to some confusion when performing interface assertions. type Fooer interface {
Foo()
}
type Example struct{}
func (e filled *Example) Foo() {}
func example() {
var blob interface{} = &Example{}
fooer, ok := blob.(Fooer)
// Is this ok or not? I have a non-nil *Example, so calling Foo should be safe!
} I think this could lead to the same sort of confusion as "not- I think it would be best to not distinguish between |
@Nathan-Fenner Thanks for the great analysis. |
I would definitely like to have non nillable types in Go (I even wrote an experience report). But @Nathan-Fenner makes some very good points. And I want to add to that with another reason why it currently doesn't fit well in Go: error handling. When there's an error right now you normally return a zero value as well. So what do you do when the return type is nonnil/filled? Do you make it nillable for every function that can return an error? |
Another place nil values appear is in struct fields, meaning non-zero-ness would have to be contagious, or else such structs would themselves have to be marked as filled before any filled fields can be treated as non-zero. Regarding Another factor in designing this is that there are many places where Go has a contract based on multiple values. For example
Generally, the contract is that if the error is nil, then the One way to allow non-filled values to be promoted to filled that would be relatively painless to use would be flow-aware typing. Basically, if you have Another way would be to support assertions, like This suggests a potential solution to the multi-value contracts problem. If Go had a couple built-in generic types, like Speaking of generics. The existence of types without zero values suggests that generic code would have to behave as if all type parameter types don't have zero values unless constrained by a some sort of zeroable bound. This complicates adding generics. |
Regarding slices and |
I think you wanted to write |
@Nathan-Fenner Regarding the zero value when accessing maps, slices, and channels of filled typesGood point! After thinking a lot, several ideas came to my mind but I think a suggestion by When declaring/creating a map, slice or channel of a filled type, you need to provide a function that provides a default value to be used in those situations where a zero value must be returned. The syntax (one of the trickiest part in this case) for this would be consistent with the section "Converting a nillable type to its fillable type" Here are examples for each container type and for all the different ways of initialization:
func zeroIntPointer() filled *int {
return new(int)
}
// With make
var m = make(map[string]filled *int, zeroIntPointer)
// With make and capacity
var m = make(map[string]filled *int, 100, zeroIntPointer)
// With map literal
var legs = 4
var m = map[string]filled *int{
"Dog": &legs,
"Cat": &legs,
"Octopus": &legs,
}(zeroIntPointer)
func zeroProcessor() filled func() error {
return func() error {
return nil
}
}
// With make
var c = make(chan filled func() error, zeroProcessor)
// With make, buffered channel
var c = make(chan filled func() error, 10, zeroProcessor)
type defaultStringer struct {}
func(defaultStringer) String() string {
return ""
}
func zeroStringer() filled fmt.Stringer{
return defaultStringer{}
}
// With make
var s = make([]filled fmt.Stringer, 3, zeroStringer)
// With literal
var s = []filled fmt.Stringer{a, b, c}(zeroStringer) This is the most flexible way I have come up with. With this, we don't need to do any exceptions to the already-established pattern of returning zero-values from maps, slices, and channels when using filled types (which is one of the intentions of this proposal: try to be orthogonal and less disruptive as possible). What is more, if Generics make it to the language one day, those functions to provide the default value could be greatly simplified, as even the standard library could provide generic implementations for some cases: // This could be in the standard library
func zeroPointer(type T)() filled *T {
return new(T)
}
// Then we could use it in the Maps example There is always the alternative of forbidding having maps, slices, and channels of filled types. You can always use normal types in those cases. For me, this would be too restrictive. |
// With make
var c = make(chan filled func() error, zeroProcessor)
// With make, buffered channel
var c = make(chan filled func() error, 10, zeroProcessor) If we were to go for zero processors, I'd suggest to at least keep the arguments always the same. So argument 2 is always capacity. If you want to declare a non-buffered channel, one would do: // With make (non-buffered)
var c = make(chan filled func() error, 0, zeroProcessor) |
Thanks for the extensive proposal. It would be nice to have ways to make the language safer. But this approach seem out of place with how Go works today. Go currently has only one rarely-used type qualifier (send-only and receive-only channels). If people started using The notion of the zero value of a type is built into the language at a pretty deep level, as @Nathan-Fenner discusses in the comment above. Adding additional arguments to We would also need additional functions in the reflect package, to return initialized values of filled types. All of this additional complexity only helps us avoid some programming errors, at least some of which can be detected by static analysis. Is this change worth the cost? |
Thanks for the comment @ianlancetaylor . Yes, I'm aware of the added complexity and it is something I don't like much, but I haven't had time lately to simplify the proposal. For me and other folks I know, forgetting about Some quick comments about some of your sentences:
Yes, I thought about this. My first attempt was to make all nilable types "filled" by default, and then you would need to use the modifier
I know! To be honest, I'm not proud of that workaround. Once I get some time, my idea is to explore the zero value "issue" in more detail and see if there is something we can do to make the zero value of all the current "nilable" types useful (without being nil), in the same way as the zero value of strings or slices. |
For the reasons given above at #33078 (comment), this is a likely decline. There is probably something we can do in this area, based on static checking, and perhaps annotations and dynamic checking. Complicating the type system doesn't seem like the right approach for Go. Leaving open for four week for further comments. |
All right. Thanks for your time |
Will do. |
Table of contents
filled
types (or struct with filled fields)nil
Introduction and justification
The main idea of this proposal is to achieve safer applications by having a mechanism to ensure, at compile time, that a type can't be `nil`.For example, when you have functions that receive a pointer or an interface, like this:
It doesn't make sense for those parameters to be
nil
(except the slice). It is almost always an error to passnil
and, if we don't add the corresponding:The program could crash at some point with an
Invalid memory address or nil pointer dereference
error.We could use
defer-recover
to try to solve this somehow, but if you have spawned a goroutine and it panics due to anil
dereference, the whole program crashes, no matter if you haddefer-recover
in your main function.This is especially dangerous in servers. Take the following example:
This is a simple web server with a handler that could panic. In this contrived example, it always panics because "pointer" is nil.
This is not that problematic, as our server keeps running and receiving other requests after that handler panicked.
However, imagine that the handler spawns a goroutine and that goroutine panics:
Again, this handler panics, but this time our whole server is down. The application has crashed.
This is a really important problem.
Finally, but not least, it is very common to use a pointer to structs when passing them to or returning them from functions to avoid the cost of copying them, even if the function is not meant to modify its content:
Ideally, we use pointers as function parameters to express that:
But most of the cases (in the majority of the Go code I have seen) they are used as an optimization to avoid copying. This is good, but then we lose the real intent of the function so we end up having the need to always check for nil if we don't want to crash.
Note: This proposal is not meant to be final nor address all panics. The idea is to get feedback and keep iterating it if we all find it useful
Note 2: I have tried the proposal to be as backward compatible as possible (see section "Backward compatibility"). Please check the "Alternatives" to see other variations that are not backward compatible
Syntax
For this proposal, I will use the
filled
type modifier, although this could change.Expand this for more details
We need a way to express that a type can't be nil by using some kind of syntax. However, I don't want the syntax to get too much attention as it is not important right now (it will be later). Here I show some options if you want to play with them:
The idea would be to choose one that is short and meaningful (the Holy Grail). Let's choose
filled
for now. We could change it later if this proposal moves forward.Proposal description
Go has the following nillable types:
pointers
,functions
,maps
,interfaces
,channel
andslices
. Nilslices
, however, are equivalent in behavior to empty slices, so I'd say we don't need to express that a slice is not nil (we can decide this later).This proposal suggests to add a type modifier to express that a nillable type cannot be
nil
, so it can be checked at compile time.For example, the following normal Go code is correct:
But it could panic if we do
x.Read(data)
.We could write the same code this way:
And it won't compile, as a
filled io.Reader
type can't be nil.This is the basic idea, let's go with the details:
Filled types don't have zero value
You must initialize a filled type with some value different than
nil
. This is a requirement to guarantee the filled type is never nil.If you want to benefit from a zero value, use a nillable type.
Expand this for more details
If a struct contains a field of a fillable type, then it doesn't have a zero value and you must initialize it:
Preallocated arrays and slices of
filled
types (or struct with filled fields)We must provide an initial value when using
make
to preallocate the slice.Expand this for more details
As filled types do not have zero value, you could not preallocate a slice/array of filled types with
make
:The easiest thing to do here is to forbid preallocating arrays of filled types with
make
.You can always use the nillable equivalent type (and use nillable pointers in the case of a struct with filled fields)
I think this is something too restrictive and there is a way to overcome this. We can extend
make
to allow specifying the initial value for the slice elements with a function.The previous example would be like this:
By using this new version of make, everything would compile correctly.
If this kind of initialization degrades the performance for huge slices (I guess it depends on how it is done internally, which is beyond my current expertise), then use the nillable equivalent type.
Converting a filled type to its nillable type
This has no problems. Following with the Go style, we should be explicit about the conversion:
Converting a nillable type to its fillable type
We must provide an initial value when doing the conversion.
Expand this for more details
This is a bit more problematic, as we need a way to guarantee the value is not nil. We can go down two paths:
if v != nil
check). This seems too complicatedI think number 2 is simpler, more explicit and easier to understand for a person reading the code.
How to provide the default value?
The simplest and most Go-like way I have found is by providing an extra argument in the type conversion, like this:
A legibility improvement could be the following: if the filled type we want to convert to has the equivalent nillable type, then we can avoid specifying the nillable type:
A more complex example with functions:
Short variable declarations
In Go, when we use one of the forms of variable declarations where we don't specify the type (
:=
orvar x =
), the type is inferred from the right expression.There will be nothing new here: if the right expression is of a filled type, then the variable would of that filled type.
If the right expression is of a nillable type and you want the variable to be of the equivalent filled type, then apply the conversion rules specified above.
Expand this for more details
Maybe we could consider simplifying some obvious cases, like "filledPointerToTheAnswer" and "filledPointerToPet", and do not require a default value, but that is not a requirement for the proposal
Comparing filled types with
nil
It doesn't compile.
nil
is not a possible value for filled types. It would be like comparing an "int" or "string" tonil
.Method set of filled pointer types
There is nothing unexpected. The method set of a filled pointer type
filled *T
is the set of all methods declared with receiverT
,*T
orfilled *T
.In other words: the method set of
filled *T
is the same as*T
plus those methods defined withfilled *T
receiver.Expand this for more details
Put it in the form of a table:
T
T
*T
T
and*T
filled *T
T
,*T
, andfilled *T
Compatibility with functionality that requires a zero value
I can only think of one case that can be problematic:
reflect
package. There is a function to get the zero value of the type:reflect.Zero(<type>)
. If the type is filled, that function will panic. Of course, there would be an extra function to know if the type is filled (reflect.IsFilled()
, etc.)Backward compatibility
The only thing that could not be backward compatible is the addition of the new keyword
filled
. Maybe we could find a word that is less commonly used or a symbol (although I prefer a word). We can discuss this if this proposal moves forward.A note about performance
Right now, the compiler needs to add a check for any nillable type so that, if it is nil, a panic is launched. This is not needed anymore with filled types so the check can be removed.
Would this mean that accessing to filled type would be more performant than to nillable type?
Alternatives
Click to show them
These are the alternatives that I discarded for some reason. However, they are useful for context and because maybe a person finds the solution to its drawbacksA) Define zero values for filled types
In this variation, filled types do have zero value. They will be the following:
filled *T
filled map[K]V
filled func
error
, then a default error is returned saying the function is a "no operation"filled chan T
filled interface
noop
function described aboveThis improves the current proposal in the following aspects:
make
function for creating preallocated array/slicesHowever, I discarded it because of the following problems:
B) Filled types is the default and we have nillable types
This would reverse the roles. For example,
var p *int
could not containnil
and it would be a compilation error if you assignnil
to it. If you want it to contain nil, then you would need to use its correspondingnillable
type:var p nillable *int
.As you might expect, this is absolutely no-backward compatible and will break practically all existing Go programs in a way that would be really hard to fix. There is no such value in this proposal that justifies that.
C) Remove nillable types and have only filled types
I thought that programs would be safer if no type can be
nil
. However, Go is pretty balanced and, in that balance, performance plays an important role. Sometimes it is useful to avoid allocating pointers or interfaces.Another point against this alternative is that
nil
can be useful in a behavioral way. Not only nil channels can be leveraged, but also you can use a nillable type to "communicate" the intention that the value could contain nothing.I think that having the two possibilities (nillable and non-nillable types) can bring even more value than only having non-nillable types.
Other similar proposals
I have been inspired by the following similar proposals:
The text was updated successfully, but these errors were encountered: