-
Notifications
You must be signed in to change notification settings - Fork 18.8k
Description
Background
By far the most common cause for panics (for me at least) is nil pointer dereferences - the billion dollar mistake.
This problem isn't unique to golang, but a number of languages are designed from the ground up to avoid it - FP languages for example, as well as rust. They use discriminated unions to mark which objects can be nil, and which can't. However these solutions are difficult to retrofit on a language that's more than a decade old.
C# took a different approach - using annotations to mark which references could be null, and warning if you ever dereferenced a nullable reference. Flow analysis could promote a nullable reference to a non-nullable one. See here for more details.
This approach was an enormous amount of work for the C# team, but extraordinarily successful. Whilst there are a long tail of cases where the flow analysis isn't quite sophisticated enough to realize a reference can't be null, or flow analysis holes where the compiler thinks a null reference is non-null, overall NullReferenceExceptions have basically become practically non-existent.
It's also had another useful advantage - developers can now safely use null as a marker type, without having to document first that the method can return null, and hoping that the developer actually reads the docs.
I think it would be valuable for go to consider a similar approach.
Example
I would suggest a ? prefix before a type to indicate that the type allows nil as a valid value.
var str1 ?*string
_ = str1[0] //warning - nillable pointer
if someCondition {
str1 = &"hello world"
_ = str1[0] // no warning - str1 is not nil here
}
var str2 *string = str1 // warning - cannot assign nillable pointer to not nil pointer
if str1 != nil {
str2 = str1 // no warning - str1 is not nil here
}Syntax
Type = TypeName | TypeLit | "(" Type ")" | "?" Type.
TypeName = identifier | QualifiedIdent .
TypeLit = ArrayType | StructType | PointerType | FunctionType | InterfaceType |
SliceType | MapType | ChannelType .
It would be illegal for a type to have multiple "?" annotations, but the subparts might all be annotated separately. E.g. ?*?map[?interface{}]?func(). This would be a nillable pointer to a nillable map from nillable interfaces to nillable funcs.
Semantics
Flow analysis is used to determine at all points whether a storage location is nillable or not. Nillability is updated when a location is assigned to, or when a location is checked to see if it's nil.
A warning will occur when a storage location which is currently nillable is
- dereferenced
- assigned to a variable marked as not nil
- passed as a parameter to a func if the parameter is marked as not nil
- appended to a slice where the slice element type is not nil
- used as key/value in a map where the key type/value type is marked as not nil
- used as a receiver of a func if the reciever is marked as not nil
etc.
It will be possible to suppress such warnings via some syntax, possibly by appending ! to the expression like C#.
When initializing a struct it would be required to initialize all non-nillable fields. For example:
type MyStruct struct {
*string Field1
?*string Field2
string Field3
}
_ = MyStruct{} // warning: Field1 is nil
str := "Hello World"
_ = MyStruct{Field1: &str1} // no warningSlices
Slices made with make appear to have a type hole:
slice := make([]*string, 5)
_ = *slice[0] // panic: nil pointer dereferenceAs a result it should be illegal to use make to create a slice with non-0 length of a non-nillable type. This should be perfectly acceptable as there is a trivial workaround - set the capacity to the desired total size, and then append the elements one by one.
Error Handling
When a method returns a non-nil error, any other return values which are usually non-nillable would not be required to be nil.
As a result, accessing such a value before checking if the err is nil, will lead to a warning.
E.g.
func getString(succeed bool) (*string, error) {
if succeed {
str := "Hello World"
return &str, nil
}
return nil, errors.New("Failed") // no warning
}
str, err := getString()
_ = *str // warning: nil dereference
if err != nil {
return err
}
_ = *str // no warningBreaking changes
This proposal requires adding warnings in places where there previously weren't any. On the other hand it should not change semantics of any existing code, just add extra diagnostics.
C#s solution to this was to gate the feature behind a compiler switch, which could be enabled at the level of a line, a file, a project, etc. In C# 9.0 this was off by default, and in C# 10 it was enabled by default, giving some time to migrate. By now most maintained projects have this feature enabled, at least for some code.
Go could go for a similar solution here, but I would fully understand if this is too unpalatable.
Template Questions
Would you consider yourself a novice, intermediate, or experienced Go programmer? intermediate
What other languages do you have experience with? Significant work experience with C#, Scala, Python. Passing familiarity with a number of others.
Would this change make Go easier or harder to learn, and why? It would add an extra concept to learn, but reduce the number of panics encountered by beginners, which will make the learning experience smoother and less frustrating. Overall a bit of a wash.
Has this idea, or one like it, been proposed before? A brief search couldn't find anything similar.
Who does this proposal help, and why? This should help all users by reducing a common kind of bug.
What is the cost of this proposal? This has a very high cost in compiler work. For C# this was the second most expensive feature ever added, after generics. Go is a simpler language, so it should be less expensive, but still a lot of work. It also requires updating all existing code to get the full benefits of this proposal.
What is the compile time cost? This requires a flow analysis pass which is the main cost. It's not insignificant, but is a very well understood problem, with lots of algorithms which can do this efficiently as the analysis can be conservative.
What is the run time cost? None
Can you describe a possible implementation? C#/roslyn would be the best example too look at for possible implementations
Is the goal of this change a performance improvement? no
Does this affect error handling? Yes, as described above.
If so, how does this differ from previous error handling proposals? No connection
Is this about generics? No
Other
I have done significant work on nullable reference types in the C# compiler (roslyn) and am friendly with most of the team. If you are interested in looking at roslyn as prior art, I can help or put you in touch with members of the team.