This document describes a language feature proposal to immutability for the Go programming language. The proposed feature targets the current Go 1.x (> 1.11) language specification and doesn't violate the Go 1 compatibility promise. It also describes an even better approach to immutability for a hypothetical, backward-incompatible Go 2 language specification.
Author | Roman Sharkov (roman.scharkov@gmail.com) |
Status | Public (#27975) |
Version | 1.0.1 |
Table of Contents
- Go - Immutability
- 1. Introduction
- 2. Proposed Language Changes
- 2.1. Immutable Fields
- 2.2. Immutable Methods
- 2.3. Immutable Arguments
- 2.4. Immutable Return Values
- 2.5. Immutable Variables
- 2.6. Immutable Interface Methods
- 2.7. Slice Aliasing
- 2.8. Address Operators
- 2.9. Immutable Reference Types
- 2.10. Immutable Package-Scope Variables
- 2.11. Explicit Type Casting
- 2.12. Implicit Casting
- 2.12. Standard Library
- 3. Immutability by Default (Go >= 2.x)
- 4. FAQ
- 4.1. Are the items within immutable slices/maps also immutable?
- 4.2. Go is all about simplicity, so why make the language more complicated?
- 4.3. Aren't other features such as generics and better error handling not more important right now?
- 4.4. Why overload the
const
keyword instead of introducing a new keyword likeimmutable
etc.? - 4.5. How are constants different from immutable types?
- 4.6. Why do we need immutable receivers if we already have copy-receivers?
- 4.7. Why do we need immutable interface types?
- 4.8. Doesn't the
const
qualifier add boilerplate and make code harder to read? - 4.9. Why do we need the distinction between immutable and mutable reference types?
- 4.10. Why implicitly cast mutable to immutable types?
- 4.11. Can't these problems be solved by a linter?
- 5. Other Proposals
Immutability is a technique used to prevent mutable shared state, which is a very common source of bugs, especially in concurrent environments, and can be achieved through the concept of immutable types.
Bugs caused by mutable shared state are not only hard to find and fix, but they're also hard to even identify. Such kind of problems can be avoided by systematically limiting the mutability of certain objects in the code. But a Go 1.x developer's current approach to immutability is manual copying, which lowers runtime performance, code readability, and safety. Copying-based immutability makes code verbose, imprecise and ambiguous because the intentions of the code author are never clear.
Immutable types can help achieve this goal more elegantly improving the safety, readability, and expressiveness of the code. They're based on 5 fundamental rules:
- I. Each and every type has an immutable counterpart.
- II. Assignments to objects of an immutable type are illegal.
- III. Calls to mutating methods (methods with a mutable receiver type) on objects of an immutable type are illegal.
- IV. Mutable types can be cast to their immutable counterparts, but not the other way around.
- V. Immutable interface methods must be implemented by a method with an immutable receiver type.
These rules can be enforced by making the compiler scan all objects of immutable types for illegal modification attempts, such as assignments and calls to mutating methods and fail the compilation. The compiler would also need to check, whether types correctly implement immutable interface methods.
Ideally, a safe programming language should enforce immutability by default where all types are immutable unless they're explicitly qualified as mutable because forgetting to make an object immutable is easier, then accidentally making it mutable. But this concept would require significant, backward-incompatible language changes breaking existing Go 1.x code. Thus such an approach to immutability would only be possible in a new backward-incompatible Go 2.x language specification.
To prevent breaking Go 1.x compatibility this document describes a
backward-compatible approach to adding support for immutable types by
overloading the const
keyword (see here for more
details)
to act as an immutable type qualifier.
The current approach to immutability (namely copying) has a number of disadvantages listed below and sorted by importance in descending order.
The absence of immutable types can lead to ambiguous code that results in dangerous, hard to find bugs. Consider the following method definition:
// Method ...
func (r *T) Method(
a *T,
b *T,
v []*T,
) (rv *T) {/*...*/}
The above code is ambiguous, it doesn't represent the intentions of its original author:
- Will it produce any side-effects on
r
? - Will it mutate the
T
s referenced bya
andb
? - Will it mutate
v
? - Will it mutate any
T
referenced by any item ofv
? - Is the
T
referenced byrv
allowed to be mutated?
All those questions can lead to bugs if they're not properly answered, and documentation never answers them reliably.
If the above function is exported from a 3-rd party package xyz
that's
imported to a project P
as an external dependency and the documentation
promises (or "claims") to not mutate any of the symbols, the code in P
will be
written with this assumption in mind.
At any time the vendor of xyz
might change its behavior, either intentionally
or unintentionally, which will introduce bugs and data corruption:
a
,b
,v
or any items ofv
might get aliased and mutated either directly (in the scope ofxyz.T.Method
) or indirectly (at an unspecified point in time).- New side-effects could be introduced to
r
. rv
might get aliased and introduce unwanted side-effects when mutated by thexyz.T.Method
caller.
In the worst case, the maintainers of P
won't even be informed about the
mutations that were unintentionally introduced to a newer version of
xyz.T.Method
through a bug in its implementation. But even if the vendors of
xyz
correctly update the changelog and the documentation introducing new
intentional side-effects, chances are high that the maintainers of P
miss the
changes in the documentation and fail to react accordingly. P
will continue to
compile, but its outputs will become corrupted which can't always be easily
detected even in the presence of extensive automated testing.
Documentation never represents actual intentions, it represents claimed intentions. Claimed intentions can't be relied on, because claims are not guaranteed to remain in sync with actual code behavior.
To avoid ambiguous code developers often describe mutability recommendations of variables, fields, arguments, methods and return values. Not only does this unnecessarily complicate and bloat up the documentation, but it also makes it error-prone and redundant. Documentation can easily get out of sync with the actual code because it can't be verified algorithmically.
Copies are the only way to achieve immutability in Go 1.x, but copies inevitably degrade runtime performance. This dilemma encourages Go 1.x developers to either write unsafe mutable APIs when targeting optimal runtime performance or safe but slow and copy-code bloated ones.
Unfortunatelly, optimal performance and code safety are currently mutually exclusive, even though having both would be possible with compiler-enforced immutable types at the cost of a slightly decreased compilation time.
Currently, Go 1.x won't allow non-scalar constants such as constant slices:
const each2 = []byte{'e', 'a', 'c', 'h'} // Compile-time error
Robert Griesemer stated in his comment to proposal #6386 that this is by language design, quote:
This is neither a defect of the language nor the design. The language was deliberately designed to only permit constant of basic types.
But many developers still claim it to be a major design flaw because exclusive immutability for scalar types only leads to inconsistency in the language design. What most developers really need is not constants of arbitrary types but rather immutable package-scope variables, which can be implemented consistently with the help of immutable types:
var each2 const []byte = []byte{'e', 'a', 'c', 'h'}
Even though technically each2
is not a constant but an immutable
package-scope variable - it solves the mutability problem.
Support for immutable types would provide the benefits listed below and sorted by importance in descending order.
Immutable types make APIs less ambiguous.
With immutable types the situations described in the previous section wouldn't even be possible, because the author of the function of the external package would need to explicitly qualify immutable types as such to make the compiler enforce the guarantee:
// Method ...
func (r * const T) Method(
a * const T,
b * T,
v const [] * const T,
) (
rv * const T,
rv2 * T,
) {
/*...*/
}
The above code is unambiguous and precise. It clearly represent the intentions of its original author and answers all critical questions reliably:
- Will it produce any side-effects on
r
?- No, it can't. Its receiver is immutable
- Will it mutate the
T
referenced bya
?- No, it can't. The
T
referenced bya
is immutable
- No, it can't. The
- Will it mutate
v
?- No, it can't.
v
is immutable
- No, it can't.
- Will it mutate any
T
referenced by any item ofv
?- No, it can't. The
T
s referenced by any item ofv
are immutable
- No, it can't. The
- Is the
T
referenced byrv
allowed to be mutated?- No, it's not. The
T
referenced byrv
is immutable
- No, it's not. The
- Will it mutate the
T
referenced byb
?- Yes, it potentially will!
- Is the
T
referenced byrv2
allowed to be mutated?- Yes, it won't lead to unwanted side-effects
Whenever a mutable type is taken, returned or provided it's assumed that its state will potentially be mutated.
The user of this function would make decisions based on the actual function definition in the code instead of relying on the potentially inconsistent documentation.
If the vendors of this function decide to change the mutability of either an input or output type or the mutability of the object the method operates on - they will have to change the type introducing breaking API changes causing compiler errors and making the user pay attention to whether or not everything's right.
The vendors won't be able to just silently introduce mutations causing bugs! The compiler will prevent this from happening either before the vendors release the update (assuming that the code is compiled by a CI/CD system before publication) or during the user's local build in the worst case.
With immutable types, there's no need to explicitly describe mutability recommendations in the documentation. When immutable types are declared as such then the code becomes self-explaining:
- An argument or a variable of an immutable type can be relied on not being changed neither inside the scope it's declared in, nor in the scopes it's passed to.
- An immutable method (or a "function with a receiver of an immutable type" if you will) - can be relied on not changing the object it operates on.
- A return value of an immutable type can be relied on not being changed by the function caller.
- A field of an immutable type can be relied on not being changed as soon as the object is initialized, even inside the scope of its origin package.
Immutability provides a way to safely avoid unnecessary copying as well as unnecessary indirections through mutable and immutable interfaces (because interfaces do have a cost).
Immutability also makes specific compiler optimizations possible. Whether or not those optimization opportunities are exploited later on is rather irrelevant to this particular proposal.
The language must be adjusted to support the const
qualifier inside type
definitions to qualify certain types as immutable.
The compiler must enforce the following rules:
- Immutable types are declared with the
const
qualifier prepended. - Assignments to objects of an immutable type are illegal.
- Calls to mutating methods (methods with a mutable receiver type) on objects of an immutable type are illegal.
- Calls to mutating interface methods on immutable interface references are illegal.
- Immutable types cannot be cast to their mutable counterparts.
- Types must implement immutable interface methods using an immutable receiver type.
- Mutable types are implicitly cast to their immutable counterparts.
- During method calls - pointer receivers must be implicitly cast in both directions (allowing immutable to mutable cast) if the types of the objects they're pointing to are equal.
It is to be noted, that all proposed changes are fully backward-compatible and don't require any breaking changes to be introduced to the Go 1.x language specification. Code written in previous versions of Go 1.x will continue to compile and work as usual.
Struct fields of an immutable type can only be set during the object initialization and are then immutable for the entire lifetime of the object within any context.
type Object struct {
ImmutableField const * const Object // Immutable
MutableField *Object // Mutable
}
// MutatingMethod is a non-const method
func (o *Object) MutatingMethod() {
o.MutableField = &Object{}
o.ImmutableField = &Object{} // Compile-time error
}
func main() {
// Immutable fields are immutable once the object is initialized
obj := Object{
ImmutableField: &Object{
ImmutableField: nil,
},
}
obj.MutableField = &Object{}
obj.ImmutableField = nil // Compile-time error
obj.ImmutableField.ImmutableField = &Object{} // Compile-time error
}
Expected compilation errors:
.example.go:9:23 cannot assign to immutable field (Object.ImmutableField) of o (type const * const Object)
.example.go:21:25 cannot assign to immutable field (Object.ImmutableField) of obj (type const * const Object)
.example.go:22:40 cannot assign to contextually immutable field (Object.ImmutableField) of obj (type const * const Object)
Immutable methods are declared using the const
qualifier on the
function-receiver and guarantee to not mutate the receiver in any way when
called. They can safely be used in immutable contexts, such as within other
immutables methods and/or on immutable objects.
Technically, this feature should rather be called "immutable function receivers".
type Object struct {
mutableField *Object // Mutable
}
// MutatingMethod is a non-const method.
func (o *Object) MutatingMethod() const * const Object {
o.mutableField = &Object{}
return o.ImmutableMethod()
}
// ImmutableMethod is a const method.
// It's illegal to mutate any fields of the receiver.
// It's illegal to call mutating methods of the receiver
func (o * const Object) ImmutableMethod() const * const Object {
o.MutatingMethod() // Compile-time error
o.mutableField = &Object{} // Compile-time error
o.mutableField.mutableField = &Object{} // Compile-time error
return o.mutableField
}
func main() {
obj := * const Object (&Object{})
obj.ImmutableMethod()
obj.MutatingMethod() // Compile-time error
}
Expected compilation errors:
.example.go:15:7 cannot call mutating method (Object.MutatingMethod) on immutable o (type * const Object)
.example.go:16:21 cannot assign to contextually immutable field (Object.mutableField) of o (type * const Object)
.example.go:17:34 cannot assign to contextually immutable field (Object.mutableField) of o (type * const Object)
.example.go:24:9 cannot call mutating method (Object.MutatingMethod) on immutable obj (type * const Object)
Immutable types can be used to guarantee the immutability of function arguments.
type Object struct {
MutableField *Object // Mutable
}
// MutatingMethod is a non-const method
func (o *Object) MutatingMethod() {}
// ImmutableMethod is a const method
func (o * const Object) ImmutableMethod() {}
// MutateObject mutates the given object
func MutateObject(obj *Object) {
obj.MutableField = &Object{}
}
// ReadObj is guaranteed to only read the object passed by the argument
// without mutating it in any way
func ReadObj(
obj * const Object // Mutable reference to immutable object
) {
obj = nil // fine, because the pointer is mutable
MutateObject(obj) // Compile-time error
obj.MutatingMethod() // Compile-time error
obj.MutableField = &Object{} // Compile-time error
}
Expected compilation errors:
.example.go:23:19 cannot use obj (type * const Object) as type *Object in argument to MutateObject
.example.go:24:9 cannot call mutating method (Object.MutatingMethod) on immutable obj (type * const Object)
.example.go:25:23 cannot assign to contextually immutable field (Object.MutableField) of obj (type * const Object)
Immutable types can be used to guarantee the immutability of a function's returned values.
type Object struct {
MutableField *Object // Mutable
}
// MutatingMethod is a non-const method
func (p *Object) MutatingMethod() {
p.MutableField = &Object{}
}
// ReturnImmutable returns an immutable value
func ReturnImmutable() const * const Object {
return &Object{
MutableField: &Object{
MutableField: &Object{},
},
}
}
func main() {
immutableVariable := ReturnImmutable()
immutableVariable.MutableField = &Object{} // Compile-time error
immutableVariable.MutatingMethod() // Compile-time error
}
Expected compilation errors:
.example.go:22:37 cannot assign to contextually immutable field (Object.MutableField) of immutable immutableVariable (type const * const Object)
.example.go:23:23 cannot call mutating method (Object.MutatingMethod) on immutable immutableVariable (type const * const Object)
Immutable types can be used to declare immutable variables.
type Object struct {
MutableField *Object // Mutable
}
// MutatingMethod is a non-const method
func (o *Object) MutatingMethod() {}
// ImmutableMethod is a const method
func (o * const Object) ImmutableMethod() {}
// NewObject creates and returns a new mutable object instance
func NewObject() *Object {
return &Object{}
}
// MutateObject mutates the given object
func MutateObject(obj *Object) {
obj.MutableField = &Object{}
}
// ConstRef helps shortening declaration statements
type ConstRef const * const Object
func main() {
// The definition version:
// The cast is necessary because NewObject returns a mutable value
// while we want an immutable variable
obj := const * const Object (NewObject())
// The var declaration version:
// (this statement could be shortened using a type alias)
var obj_var_long const * const Object = NewObject()
var obj_var ConstRef = NewObject()
obj.MutableField = &Object{} // Compile-time error
obj.MutatingMethod() // Compile-time error
MutateObject(obj) // Compile-time error
}
Expected compilation errors:
.example.go:23:23 cannot assign to contextually immutable field (Object.MutableField) of obj (type const * const Object)
.example.go:24:9 cannot call mutating method (Object.MutatingMethod) on immutable obj (type const * const Object)
.example.go:25:19 cannot use obj (type const * const Object) as type *Object in argument to MutateObject
Interfaces can be obliged to require receiver type immutability using the
const
qualifier in the method declaration to prevent the implementing function
from mutating the object referenced by the interface.
// Interface represents a strict interface with an immutable method
type Interface interface {
// Read must not mutate the underlying implementation
const Read() string
// Write can potentially mutate the underlying implementation
Write(string)
}
// ValidImplementation represents a correct implementation of Interface
type ValidImplementation struct {
/*...*/
}
// Read correctly implements Interface.Read, it has an immutable receiver
func (r * const ValidImplementation) Read() string {
/*...*/
}
// Write correctly implements Interface.Write,
// even though the receiver is immutable
func (r * const ValidImplementation) Write(s string) {
/*...*/
}
// InvalidImplementation represents an incorrect implementation of Interface
type InvalidImplementation struct {
/*...*/
}
// Read incorrectly implements the immutable Interface.Read,
// the receiver must be of type: * const InvalidImplementation
func (r * InvalidImplementation) Read() string {
/*...*/
}
// Write correctly implements Interface.Write
func (r * InvalidImplementation) Write(s string) {
/*...*/
}
func main() {
var iface Interface = &InvalidImplementation{} // Compile-time error
iface.Write(0, "example")
var readOnlyIface const Interface = &ValidImplementation{}
readOnlyIface.Read()
readOnlyIface.Write() // Compile-time error
}
.example.go:43:26: cannot use InvalidImplementation literal (type *InvalidImplementation) as type Interface in assignment:
*InvalidImplementation does not implement Interface (Read method has mutable pointer receiver, expected an immutable receiver type)
.example.go:48:19: cannot call mutating method (Interface.Write) on immutable readOnlyIface (type const Interface)
Immutability of slices is always inherited from their parent slice. Sub-slicing immutable slices results in new immutable slices:
func ReturnConstSlice() const []int {
return []int {1, 2, 3}
}
func main() {
originalSlice := const([]int{1, 2, 3})
subSlice := originalSlice[:1]
originalSlice[0] = 4 // Compile-time error
subSlice[0] = 4 // Compile-time error
anotherSubSlice := ReturnConstSlice()[:1]
anotherSubSlice[0] = 4 // Compile-time error
}
.example.go:9:23 cannot assign to immutable originalSlice (type const []int)
.example.go:10:18 cannot assign to immutable subSlice (type const []int)
.example.go:13:25 cannot assign to immutable anotherSubSlice (type const []int)
Taking the address of an immutable variable results in a mutable pointer to an immutable object:
var t const T = T{}
t_pointer := &t // * const T
To take an immutable pointer from an immutable variable explicit casting is necessary:
var t const T = T{}
t_pointer1 := const(&t) // const * const T
// Or like this:
t_pointer2 := const * const T(&t) // const * const T
Dereferencing an immutable pointer to an immutable object:
t := const * const T (&T{})
*t // const T
Dereferencing a mutable pointer to an immutable object:
t := * const T (&T{})
*t // const T
Dereferencing an immutable pointer to a mutable object:
t := const * T (&T{})
*t // T
Reference types such as slices,
maps,
channels and
pointers can also be declared
immutable using the const
qualifier just like any other type. But the objects
/ items referenced by immutable reference types don't inherit their
immutability! Reference types can point to both mutable and immutable types,
this makes the type system very versatile and flexible.
The examples below demonstrate a few possible combinations:
var immut2mut const *Object = &Object{}
immut2mut = &Object{} // Compile-time error
immut2mut.Field = 42 // fine
immut2mut.Mutation() // fine
var mut2immut * const Object = &Object{}
mut2immut = &Object{} // fine
mut2immut.Field = 42 // Compile-time error
mut2immut.Mutation() // Compile-time error
var immut2immut const * const Object = &Object{}
immut2immut = &Object{} // Compile-time error
immut2immut.Field = 42 // Compile-time error
immut2immut.Mutation() // Compile-time error
var immut2immut const [] const Object
immut2immut = append(immut2immut, Object{}) // Compile-time error
immut2immut[0] = Object{} // Compile-time error
obj := immut2immut[0]
obj.Mutation() // Compile-time error
var mut2immut [] const Object
mut2immut = append(mut2immut, Object{}) // fine
mut2immut[0] = Object{} // fine
obj := mut2immut[0]
obj.Mutation() // Compile-time error
var immut2mut const [] Object
immut2mut = append(immut2mut, Object{}) // Compile-time error
immut2mut[0] = Object{} // Compile-time error
obj := immut2mut[0]
obj.Mutation() // fine
var mut2mut [] Object
mut2mut = append(mut2mut, Object{}) // fine
mut2mut[0] = Object{} // fine
obj := mut2mut[0]
obj.Mutation() // fine
var mut_immut2mut map[const Object] Object
newKey := Object{}
mut_immut2mut[newKey] = Object{} // fine
delete(mut_immut2mut, newKey) // fine
for key, value := range mut_immut2mut {
key.Mutation() // Compile-time error
value.Mutation() // fine
}
var mut_mut2immut map[Object] const Object
newKey := Object{}
mut_mut2immut[newKey] = Object{} // fine
delete(mut_mut2immut, newKey) // fine
for key, value := range mut_mut2immut {
key.Mutation() // fine
value.Mutation() // Compile-time error
}
var immut_immut2immut map[const Object] const Object
newKey := Object{}
immut_immut2immut[newKey] = Object{} // fine
delete(immut_immut2immut, newKey) // fine
for key, value := range immut_immut2immut {
key.Mutation() // Compile-time error
value.Mutation() // Compile-time error
}
var m const map[const Object] const Object
newKey := Object{}
m[newKey] = Object{} // Compile-time error
delete(m, newKey) // Compile-time error
for key, value := range m {
key.Mutation() // Compile-time error
value.Mutation() // Compile-time error
}
func main() {
ch := ConstReadOnlyChannel()
ch = AnotherChannelOfSameType() // Compile-time error
immutObj := <-ch
immutObj.Field = 42 // Compile-time error
immutObj.Mutation() // Compile-time error
}
// ConstReadOnlyChannel returns an immutable read only channel
// of immutable objects
func ConstReadOnlyChannel() const <-chan const Object {
ch := make(chan Object)
go func() {
ch <- Object{}
}()
return ch
}
func main() {
ch := ConstReadOnlyChannel()
ch = AnotherChannelOfSameType() // Compile-time error
mutObj := <-ch
mutObj.Field = 42 // fine
mutObj.Mutation() // fine
}
// ConstReadOnlyChannel returns an immutable read only channel
// of mutable objects
func ConstReadOnlyChannel() const <-chan Object {
ch := make(chan Object)
go func() {
ch <- Object{}
}()
return ch
}
func main() {
ch := MutReadOnlyChannel()
ch = MutReadOnlyChannel() // fine
immutObj := <-ch
immutObj.Field = 42 // Compile-time error
immutObj.Mutation() // Compile-time error
}
// MutReadOnlyChannel returns a mutable read only channel of immutable objects
func MutReadOnlyChannel() <-chan const Object {
ch := make(chan Object)
go func() {
ch <- Object{}
}()
return ch
}
Package-scope variables of an immutable type can be used similarly to package-scope constants. They compensate for the lack of non-scalar constants.
package library
type T struct {}
func (t * const T) MutatingMethod() {
/*...*/
}
// ConstantNames represents a package-scope immutable slice of strings
var ConstantNames const []string = []string{"Anna", "Mike", "Ashley"}
// privateImmutTInstances represents a package-scope immutable slice of pointers
// to immutable instances of T
var privateImmutTInstances const [] * const T = []*T{
&T{},
&T{},
&T{},
}
// Function represents an exported function that tries to mutate immutable
// package-scope variables
func Function() {
ConstantNames[0] = "Hannah" // Compile-time error
privateImmutTInstances[0] = &T{} // Compile-time error
constT := privateImmutTInstances[0]
constT.MutatingMethod() // Compile-time error
}
.library.go:23:23: cannot assign to immutable ConstantNames (type const []string)
.library.go:24:32: cannot assign to immutable privateImmutTInstances (type const [] * const T)
.library.go:26:12: cannot call mutating method (T.MutatingMethod) on immutable constT (type * const T)
Imported:
package main
import "github.com/x/library"
func main() {
library.ConstantNames[0] = "Hannah" // Compile-time error
}
.library.go:6:31: cannot assign to immutable library.ConstantNames (type const []string)
Mutable types are always implicitly cast to their immutable counterparts but in some situations explicit casting may also be useful.
Simple typecasting const(mt)
converts a mutable type into its immutable
counterpart:
// Simple non-const to const casting
const_string := const("test") // const string
const_pointer := const(&T{}) // const * T
const_slice := const([]int{1, 2, 3}) // const []int
}
Applying simple typecasting to already immutable types has no effect.
For more complex types simple const
casting is insufficient, thus a literal
type cast immutable type (symbol)
to an immutable type is required.
var original_slice [] [] *T
// Mutable slice of immutable slices of pointers to a mutable instance of T
s1 := [] const [] * T (original_slice)
// Immutable slice of mutable slices of pointers to an immutable instance of T
s2 := const [] [] * const T (original_slice)
// Mutable slices of mutable slices of pointers to an immutable instance of T
s3 := [] [] * const T (original_slice)
// Immutable slice of immutable slices of pointers to an immutable instance of T
s4 := const [] const [] * const T (original_slice)
var original_map map[*T]*T
// Immutable map of:
// pointers to an immutable instance of T (key) to:
// pointers to a mutable instance of T (value)
m1 := const map [* const T] * T (original_map)
// Immutable map of:
// pointers to a mutable instance of T (key) to:
// pointers to an immutable instance of T (value)
m2 := const map [* T] * const T (original_map)
// Mutable map of:
// pointers to an immutable instance of T (key) to:
// pointers to an immutable instance of T (value)
m3 := map [* const T] * const T (original_map)
Casting immutable types to mutable types is forbidden because it would make it possible to silently void the immutability guarantee breaking the entire concept of immutability.
Mutable types are implicitly cast to their immutable counterparts. This rule is
applied to any type in a type-chain. If we consider the definition of a type as
a binary sequence where mutable types are represented by 0
and immutable types
by 1
, then any conversions of 1
to 0
should cause a compile-time error.
// tip: use an 80-column wide view to make sense of the markers
// 0_ 0_ 0_ 0 1______
var origin [] [] [] * const T
/* LEGAL CONVERSIONS */
// 1_______ 0_ 0_ 0 1______
var var1 const [] [] [] * const T = origin // 00001 -> 10001
// 0_ 0_ 1_______ 0 0
var var2 [] [] const [] * T = origin // 00001 -> 00100
// 0_ 1_______ 1_______ 0 0
var var3 [] const [] const [] * T = origin // 00001 -> 01100
/* ILLEGAL CONVERSIONS */
// 0_ 1_______ 0_ 0 0 F F
var inv1 [] const [] [] * T = origin // 00001 -> 01000
// 1_______ 1_______ 1_______ 1______ 0 F F
var inv2 const [] const [] const [] const * T = origin // 00001 -> 11110
Pointer receivers are implicitly cast in both directions (mutable to immutable and vice-versa) when the types they're pointing to are equal.
Methods:
type T struct {/*...*/}
func (r1 *T) M1() {/*...*/}
func (r2 const * T) M2() {/*...*/}
func (r3 * const T) M3() {/*...*/}
func (r4 const * const T) M4() {/*...*/}
Variables:
// mutable pointer to mutable T
t1 := &T{}
// immutable pointer to mutable T
var t2 const * T = &T{}
// mutable pointer to immutable T
var t3 * const T = &T{}
// immutable pointer to immutable T
var t4 const * const T = &T{}
Combination | Compile-time Result | Reason |
---|---|---|
t1.M1() |
legal | types match. |
t2.M1() |
implicit cast | const * T (t2 ) is implicitly cast to * T (r1 ) because in both cases T is mutable. |
t3.M1() |
illegal | T referenced by t3 is immutable, but M1 is a mutating method. |
t4.M1() |
illegal | T referenced by t4 is immutable, but M1 is a mutating method. |
t1.M2() |
implicit cast | * T (t1 ) is implicitly cast to const * T (r2 ) because in both cases T is mutable. |
t2.M2() |
legal | types match. |
t3.M2() |
illegal | T referenced by t3 is immutable, but M2 is a mutating method. |
t4.M2() |
illegal | T referenced by t4 is immutable, but M2 is a mutating method. |
t1.M3() |
implicit cast | * T (t1 ) is implicitly cast to * const T (r3 ) because T is mutable and M3 is a non-mutating method. |
t2.M3() |
illegal | T referenced by t2 is mutable, but M3 is a mutating method. |
t3.M3() |
legal | types match. |
t4.M3() |
implicit cast | const * const T (t4 ) is implicitly cast to * const T (r3 ) because in both cases T is immutable. |
t1.M4() |
implicit cast | * T (t1 ) is implicitly cast to const * const T (r4 ) because T is mutable and M3 is a non-mutating method. |
t2.M4() |
implicit cast | const * T (t2 ) is implicitly cast to const * const T (r4 ) because T is mutable and M4 is a non-mutating method. |
t3.M4() |
implicit cast | * const T (t3 ) is implicitly cast to const * const T (r4 ) because in both cases T is immutable. |
t4.M4() |
legal | types match. |
Minimal backward-compatible changes to the standard library need to be made to make user-written code that takes advantage of immutable types interoperable with the standard library.
strings.Join is a typical example of a
standard library function that needs to be updated to take advantage of
immutable types. Its updated version would have to guarantee the immutability of
a
:
func Join(a const []string, sep string) string
Optionally, sep
could be declared immutable as well (const string
)
protecting it from accidental overwrites in the function scope. Making sep
immutable simply guides the function's implementor by telling him/her that sep
is not meant to be overwritten in the function's scope, it has no meaning for
the function's caller though.
This change is necessary because otherwise, the following code that takes advantage of immutable types would not compile:
// Example won't compile because the old strings.Join takes a mutable slice,
// but casting the immutable "a" to a mutable slice of strings is illegal!
func Example(a const []string) {
concat := strings.Join(a, ",") // Compile-time error
}
If strings.Join won't support immutable types, then its users will be forced to fall back to a mutable slice argument, which makes the immutability concept useless for their specific case.
If we were to think of an immutability proposal for the backward-incompatible Go
2 language specification, then making all types immutable by default and
introducing a special keyword mut
for mutability qualification would be a
better option.
// Object implements the ObjectInterface interface
type Object struct {
Immutable_str string
Mutable_str mut string
Immutable_immutRef_to_immutObj * Object
Mutable_mutRef_to_immutObj mut * Object
Mutable_mutRef_to_mutObj mut * mut Object
Immutable_immutSlice_of_immutObj [] Object
Mutable_mutSlice_of_immutObj mut [] Object
Mutable_mutSlice_of_mutObj mut [] mut Object
Immutable_immutMap_of_immutObj map[Object] Object // immutable key
Mutable_mutMap_of_immutObj mut [Object] Object // immutable key
Mutable_mutMap_of_mutObj mut [mut Object] mut Object // mutable key
}
// MutableMethod implements ObjectInterface.MutableMethod
func (mutableReceiver * mut Object) MutableMethod(
mutableArgument mut * Object, // mutable reference to immutable object
) (
mutableReturnValue mut * mut Object, // mutable reference to mutable object
) {
var mutRef_to_mutObj mut * mut Object
var mutRef_to_immutObj mut * Object
var immutRef_to_immutObj * Object
return nil
}
// ImmutableMethod implements ObjectInterface.ImmutableMethod
func (immutableReceiver *Object) ImmutableMethod(
immutableArgument * Object, // immutable reference to immutable object
) (
immutableReturnValue * Object // immutable reference to immutable object
) {
var mutRef_to_mutObj mut * mut Object
var mutRef_to_immutObj mut * Object
var immutRef_to_immutObj * Object
return nil
}
type ObjectInterface interface {
mut MutableMethod(arg mut * Object) (returnValue mut * mut Object)
ImmutableMethod(arg *Object) (returnValue *Object)
}
It's easy to forget to add the const
qualifier and accidentally make something
mutable. But when mutable types need to be explicitly declared mutable using the
mut
qualifier writing code becomes even safer.
Statistically, Most of the variables, arguments, fields, return values and
methods are immutable, thus the frequent const
qualifiers can be replaced by
fewer mut
qualifiers, which improves both readability and coding speed. The
mut
keyword is also shorter than const
.
The need for overloading of the const
keyword would vanish, which would
improve semantic language consistency.
No, they're not! As stated in Section 2.9., an immutable slice/map of mutable objects is declared this way:
type ImmutableSlice const []*Object
type ImmutableMap const map[*Object]*Object
If you want the items of an immutable slice/map to be immutable as well, you'll
need to declare them using the const
qualifier:
type ImmutableSlice const [] const * const Object
type ImmutableMap const map[*Object] const * const Object
type ImmutableMapAndKey const map[const * const Object] const * const Object
A deeply-immutable matrix could, therefore, be declared the following way:
type ImmutableMatrix const [] const [] int
The const
qualifier adds only a little cognitive overhead:
- When declaring a function argument we have to know whether we want to be able to change its state and make it immutable if we don't.
- When declaring a struct field we have to know, whether we want the state of this field to remain unchangeable, during the lifetime of an object instantiated from this struct, as soon as it's initialized.
- When declaring a return value we have to know whether we want to give the caller the permission to modify the object we returned.
- When declaring a variable we have to know, whether we want to change it in this context.
- When declaring a function-receiver we have to know, whether this function will change anything inside the receiver.
- When declaring an interface method we have to know, whether this method should not change the state of the object implementing this interface.
- When declaring a reference type such as a pointer, a slice a map or a
channel we have to know whether we want to:
- make the object changeable, but not its reference
- make the actual reference changeable, but not the object it references
- make both the reference and the object changeable
This additional cognitive overhead prevents us from introducing the complexity
created by mutable shared state. Bugs introduced through mutable shared state
are very dangerous, hard to notice, hard to identify and pretty hard to fix.
Justifying the simplicity of a language which can lead to very complex bugs is
rather incorrect when considering the insignificant overhead of the const
qualifier. Thus, immutability is a feature, the overhead of which outweighs the
disadvantages of not having it.
Example: You always have to remember to copy stuff that you don't want others to be able to mutate, or at least explicitly advise to "not mutate certain stuff" in the documentation running the risk of breaking your inattentive colleague's code:
// ConnectedClients returns the list of all currently connected clients.
// DO NOT mutate the returned slice, this could break the server!
func (s *Server) ConnectedClients() []Client {
return s.clients
}
But even if the people working with your code follow your advices, they could still mess it up:
// ThisWontMutateIt verbally promises to
// not mutate the given slice of clients
func ThisWontMutateIt(clients []Client) {
// You know what? to hell with the promise!
// I don't know where this slice originated from, so why care?
clients[len(clients) - 1] = nil
}
clients = server.ConnectedClients()
// It promised not to mutate it, so it's safe, right? right!?
ThisWontMutateIt(clients)
To improve the safety of the code above, we'd usually return a copy instead:
func (s *Server) ConnectedClients() []Client {
// Copy the client list to avoid returning an unsafe mutable reference
clients := make([]Client, len(s.clients))
for i, clt := range s.clients {
clients[i] = clt
}
return clients
}
This certainly makes both code and documentation more complicated and error prone (and slower) than it could be with immutability:
// ConnectedClients returns the list of all currently connected clients.
func (s * const Server) ConnectedClients() const []Client {
return s.clients
}
Unlike other language specification issues such as "generics" and "how to handle errors more elegantly" there's really not much to argue about in case of immutability. It should be clear that:
- it makes code both safer and easier to make sense of,
- it doesn't require any breaking changes,
- it doesn't even require a single new language keyword.
Therefore immutability should be considered of higher priority compared to other language design proposals.
Backwards-compatibility. Using the const keyword would allow us to introduce
immutability to Go 1.x without having to make breaking changes to the language.
The introduction of a new keyword could potentially break existing Go 1.x code,
where the new keyword might be used for naming symbols causing build conflicts.
const
on the other hand is already a reserved language
keyword which doesn't interfere with the
proposed language changes and verbally comes close to the desired meaning (for
example, C++ uses the const
keyword to do just that).
Short: Constants are static in memory, while immutable types are just write-protected references to mutable memory.
Long: The value of a constant is defined during the compilation and remains a static piece of memory for the entire lifetime of your program. An immutable field, argument, return value, receiver or variable, on the other hand, is not static in memory, because it can still be mutated through mutable references:
// CreateList creates a new slice and returns both, a mutable and an immutable
// reference to it (which is bad! don't ever do that!
// unless you know what you're after!)
func CreateList(size int) (mutable []string, immutable const []string) {
newSlice := make([]string, size)
for i := 0; i < size; i++ {
newSlice[i] = "sample text"
}
return newSlice, newSlice
}
func main() {
mutableReference, immutableReference := CreateList(10)
// Mutating an immutable return value is illegal
immutableReference[5] = "mutated" // Compile-time error
// Mutating the underlying array through a mutable reference is just fine!
mutableReference[5] = "mutated"
// You can now observe the mutation from the read-only immutable reference
immutableReference[5] // "mutated"
}
NOTICE: the above code is bad code! Its purpose was to demonstrate that immutable types are not constants. If you want to prevent immutable objects from being mutated for sure - drop all mutable references to it as soon as it's created!
There are two reasons: safety and performance.
Copy-receivers don't prevent mutations! They simply can't because of pointer aliasing:
type Object struct {
name string
parent *Object
children []*Object
}
// SetName is a non-const mutating method
func (o *Object) SetName(newName string) {
o.name = newName
}
// ReadOnlyMethod is insidious because it verbally "promises"
// to not touch the object while it still can do!
// Even though it has a non-pointer receiver, the copy
// can't get rid of aliasing and can thus mutate internals!
func (o Object) ReadOnlyMethod() {
if len(o.children) > 0 {
// Mutating contextually immutable aliases is legal, VERY BAD!
o.children[0] = nil
}
if o.parent != nil {
// Call non-const method in immutable context is legal, VERY BAD!
o.parent.SetName("GOTCHA!")
}
}
func main() {
obj := &Object{
name: "root",
parent: nil,
}
obj.children = []*Object{
&Object{
name: "child_1",
parent: obj,
},
&Object{
name: "child_2",
parent: obj,
},
}
fmt.Println("Root before: ", obj)
fmt.Println("Child before: ", obj.children[0])
firstChild := obj.children[0]
firstChild.ReadOnlyMethod() // Will mutate parent's name
obj.ReadOnlyMethod() // Will mutate children list
fmt.Println("Root after: ", obj) // Children list mutated
fmt.Println("Child after: ", firstChild) // Root name mutated
}
https://play.golang.org/p/0kRSuVFkSMN
Copy-receivers are slow(er). Consider the following benchmark:
type Object struct {
name string
text []rune
double float64
integer int64
bytes []byte
}
// PointerReceiver has a pointer receiver
func (o *Object) PointerReceiver() (string, []rune, float64) {
return o.name, o.text, o.double
}
// CopyReceiver has a copy receiver
func (o Object) CopyReceiver() (string, []rune, float64) {
return o.name, o.text, o.double
}
var name string
var text []rune
var double float64
// BenchmarkPointerReceiver benchmarks the pointer-receiver method
func BenchmarkPointerReceiver(b *testing.B) {
obj := &Object{}
b.ResetTimer()
for n := 0; n < b.N; n++ {
name, text, double = obj.PointerReceiver()
}
}
// BenchmarkCopyReceiver benchmarks the copy-receiver method
func BenchmarkCopyReceiver(b *testing.B) {
obj := &Object{}
b.ResetTimer()
for n := 0; n < b.N; n++ {
name, text, double = obj.CopyReceiver()
}
}
https://play.golang.org/p/2xgn7YMosXO
The results should be similar to:
goos: windows
goarch: amd64
pkg: benchreceiver
BenchmarkPointerReceiver-12 1000000000 2.12 ns/op
BenchmarkCopyReceiver-12 300000000 4.23 ns/op
PASS
ok benchreceiver 4.110s
Windows 10; i7 3930K @ 3.80 Ghz
goos: darwin
goarch: amd64
pkg: github.com/romshark/benchreceiver
BenchmarkPointerReceiver-8 1000000000 2.05 ns/op
BenchmarkCopyReceiver-8 300000000 4.62 ns/op
PASS
ok benchreceiver 4.129s
MacOS High Sierra (10.13); i7-4850HQ @ 2.30 GHz
Even though ~2 nanoseconds doesn't sound like much it's still twice as expensive to call.
Conclusion: copy-receivers are not a solution, they make your code slower without providing any protection from pointer aliasing, thus immutable receivers (be it an immutable copy or an immutable pointer receiver) are necessary to ensure compiler-enforced safety.
Immutable interface types allow us to reuse interface types disabling their mutating ability in certain scopes without having to define two separate interface types (one interface type with read methods only and another one with both mutating and non-mutating methods)
Passing an immutable interface to a function as an argument while trying to call a mutating method on it, for example, would generate a compile-time error:
type Interface {
const ReadOnly()
Write()
}
type Implementation struct {}
func (i * const Implementation) ReadOnly() {}
func (i * Implementation) Write() {}
// TakeReadInterface will not be able to execute non-const methods of the interface
func TakeReadInterface(iface const Interface) {
iface.ReadOnly() // fine
iface.Write() // Compile-time error
}
func main() {
iface := &Implementation{}
TakeReadInterface(iface)
}
.example:13:11: cannot call mutating method (Interface.Write) on immutable iface (type const Interface)
Short answer: No, it doesn't and it can be quite the opposite.
Long answer: Let's pretend we need to write a method with the following constraints:
- It must take a slice of pointers to objects of type
Object
as arguments
. - It must return all objects from an internal slice.
- It must use the function
Dependency
that's exported from a third-party packagethirdparty
and passs
to it. - The
thirdparty.Dependency
function doesn't specify whether or not it'll mutates
in the documentation. - It must not mutate
s
, neither the slice nor the referenced objects! - It must ensure the internal slice cannot be mutated from the outside!
- It must ensure, that the receiver is not mutated in any way!
Our current approach would be copying because there's no other way to ensure immutability.
/* WITHOUT IMMUTABILITY */
func (rec *T) OurMethod(s []*Object) [] *Object {
s_copy := make([] *Object, len(s))
for i, item := range s {
// Clone the items to get rid of aliasing
// Copying an aliased slice wouldn't make any sense otherwise
s_copy[i] = item.Clone()
}
// Pass a copy of "s" to third-party function to ensure it doesn't modify it
thirdparty.Dependency(s_copy)
// Now return a deep copy of the internal slice to prevent any mutations
internal_copy := make([] *Object, len(rec.internal))
for i, item := range rec.internal {
// Clone to avoid aliasing
// Copying an aliased slice wouldn't make any sense otherwise
internal_copy[i] = item.Clone()
}
return internal_copy
}
Now feel free to remove the comments and compare the above copy-bloated code
with the code protected by the const
qualifier:
/* WITH IMMUTABILITY */
func (rec * const T) OurMethod(
s const [] * const Object,
) const [] * const Object {
thirdparty.Dependency(s) // s is safe
return rec.internal // rec.internal is safe
}
If you don't like the rather verbose type definitions then consider using type aliasing to shorten the code:
type ConstSlice = const [] * const Object
func (rec * const T) OurMethod(s ConstSlice) ConstSlice {
thirdparty.Dependency(s)
return rec.internal
}
Simply put, the question is: why do we have to write out the rather verbose
const * const Object
and const [] const Object
instead of just
const *Object
and const []Object
respectively?
There are certain situations where mutable references to immutable types are necessary such as when we want to describe a dynamic, interlinked graph data structure where the nodes of the graph are immutable.
// GraphNode represents a node with outbound and inbound connections.
// Connections can be changed, but the underlying nodes will remain immutable
type GraphNode struct {
inbound * const GraphNode
outbound * const GraphNode
}
Contrary, there are other situations where we'd require immutable references to mutable objects, such as in the case of rather complex functions taking references to mutable graph nodes:
// MutateGraphNode takes an immutable pointer to a mutable graph node,
// The pointer needs to be immutable so that it behaves like an
// immutable variable so we can't accidentally change it in the scope
// of the function potentially messing up the whole calculation!
func MutateGraphNode(ref const * GraphNode) {
/*...*/
ref = &GraphNode{} // Compile-time error
/*...*/
ref.outbound = &GraphNode{} // fine
ref.Mutation() // fine
}
Without this distinction, the above code wouldn't be possible and we'd have to compromise compile-time safety by removing immutability to solve similar problems. Reference types like pointers, slices, and maps are just regular types and should be treated as such consistently without any special regulations.
It's true that in Go, types are converted explicitly with interface types being the only exception to this rule. But making the typecasting of mutable- to immutable types explicit would break backward-compatibility (see issue #14 for more details) and also make the language rather verbose.
Implementing immutable types with a linter would not solve the following problems:
-
Verbosity and Confusion: A linter would work based on type names and require explicit definitions of immutable alias types (including primitive types) with some kind of a pre- or postfix like "ImmutTypeConst", which is way too verbose and confusing compared to the
const
qualifier based approach! Nobody will ever write code like this:type ConstInt int // ConstT is an immutable T type ConstT T // ConstPointerConstT is an immutable pointer to an immutable T type ConstPointerConstT * ConstT // ConstSliceConstT is an immutable slice of pointers to immutable Ts type ConstSliceConstT [] * ConstT func (r * ConstT) Method( a ConstPointerConstT, v ConstSliceConstT, ) ( rv * ConstT, ) { var integer ConstInt = 42 /*...*/ }
...to achieve similar results as:
func (r * const T) Method( a const * const T, v const [] * const T, ) ( rv * const T, ) { var integer int = 42 /*...*/ }
-
Cross-Package Consistency: A linter wouldn't protect the code from cross-package mutability issues! If any external code like a third-party library doesn't support the immutable type name convention of the linter then the immutability simply can't be checked on these parts of the program code making the entire concept useless. The standard library will never be written in such a style, but it's essential to all Go 1.x code.
The proposed kind of immutability described in the document above doesn't solve the mutable shared state problem caused by pointer aliasing at all proposing only exceptional treatment of slices and maps passed as function arguments.
- Inconsistent: it introduces exceptional rules for map- and slice-type arguments leading to eventual specification inconsistency.
- Doesn't solve the root problem: it doesn't take mutable pointer types into account which are prone to pointer aliasing leading to mutable shared state just like slices and maps.
- Very limited: it doesn't propose immutability for variables, methods, fields, return values and arguments of any other type than slices and maps.
- Leads to performance degradation: it proposes shallow-copying of slices and maps passed to function argument instead of actual compile-time immutability errors (even though it mentions it).
- Unclear: it doesn't clearly define how to handle slice aliasing.
- Unclear: it also doesn't clearly define how to handle nested container types.
- Very limited: it won't allow different combinations of mutable and immutable types (such as passing mutable references inside immutable slices and similar).
- Similar
const
keyword overloading with the same argumentation with slight differences (used as argument field qualifier rather than as argument type qualifier).
The proposed ro
qualifier described in the document above is similar to
current proposal but still has some significant differences.
- Backward-incompatible: the proposed feature requires backward-incompatible language changes.
- Limiting and partially pointless:
ro
Transitivity describes the inheritance of immutability by types referenced by immutable references, which limits the ability of the developer to describe immutable references to mutable objects and similar. This limitation will make developers avoid usingro
types alltogether and use unsafe mutable types instead when a mix between mutable and immutable references is necessary. An immutable slice of mutable slices:const [] [] int
wouldn't be possible withro
transitivity and would leave the developer no choice but turning it into a mutable slice of mutable slices:[][]int
making the entire concept of read-only types partially pointless. - Less advanced: it doesn't propose immutable struct fields.
- "Immutability" is called "read-only type permissions" while constants are called "immutables".
- The proposed
ro
qualifier is part of the type definition just as theconst
qualifier. - Proposes immutable return values, arguments, interfaces and receivers.
- Describes very similar benefits.
Copyright © 2018 Roman Sharkov (roman.scharkov@gmail.com)