Skip to content
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: Go 2: immutable type qualifier #27975

Open
romshark opened this issue Oct 2, 2018 · 77 comments

Comments

@romshark
Copy link

commented Oct 2, 2018

This issue describes a language feature proposal to Immutable Types. It 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.

The linked Design Document describes the entire proposal in full detail, including the current problems, the benefits, the proposed changes, code examples and the FAQ.

Updates

  • October 7th: This proposal is approaching its second revision addressing major flaws such as const-poisoning, verbosity, const-keyword overloading and others.

Introduction

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. Documentation can be rather misleading and doesn't solve the problems either.

Immutable Types in Go 1.x

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.

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.

Immutable types can be used for:

Immutable Types in Go 2.x

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, than 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.

Related Proposals

This proposal is somewhat related to:

Detailed comparisons to other proposals are described in the design document, section 5..


Please feel free to file issues and pull requests, become a stargazer,
contact me directly at roman.sharkov@qbeon.com and join the conversation on Slack Gophers (@romshark), the international and the russian Telegram groups, as well as the original golangbridge, reddit and hackernews posts! Thank you!

@gopherbot gopherbot added this to the Proposal milestone Oct 2, 2018

@gopherbot gopherbot added the Proposal label Oct 2, 2018

@dsnet

This comment has been minimized.

Copy link
Member

commented Oct 2, 2018

Nice document; clearly you spent a while on it. I only briefly glanced over it.

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.

Not exactly the only way. An alternative approach is to have an opaque type with only exported methods that provide read-only access, which is how reflect.Type achieves immutability. The v2 protobuf reflection API also takes this approach. It has other downsides (like needing to manually create methods for each read-only operation), but pointing out that there are non-copy approaches to immutability.

@networkimprov

This comment has been minimized.

Copy link

commented Oct 2, 2018

I like the idea of mut more than const, but I think similar effect could be achieved with optional naming conventions (field_m, mField, field$ :-) and a go-vet switch.

Also forgetting to use const somewhere can cause havoc down the road...

EDIT: Related: #21130, #6386

@kirillDanshin

This comment has been minimized.

Copy link

commented Oct 2, 2018

@networkimprov please no $ in names, I don't think we need to start another PHP again

@ianlancetaylor

This comment has been minimized.

Copy link
Contributor

commented Oct 2, 2018

As far as I can tell in my initial skim, this doesn't solve the memchr problem. In C the standard library memchr function, which returns a pointer to the first occurence of a character in a string, is defined as char *memchar(const char *, char). The problem is that in terms of types, if memchr is passed a const char * it should return a const char *, and if memchr is passed a char * it should return a char *. That is, it should preserve the const-ness of its first argument. But there is no way to write that in C. And I don't see how to write that in your proposal.

Other comments:

  • Is there any difference between this proposal and the use of const as a type qualifier in C, other than the logical extension to interfaces?
  • Look up "const-poisoning."
  • Pedantically, I don't particularly like using the word "immutable" to describe the parameter to func F(const []int). That slice is not immutable; all that declaration says is that F will not change it. This is particularly clear if you write func F(s1 const []int, s2 []int) and then call it as F(s, s). You can't say that s1 is immutable within F, because if s2 changes then s1 will change.
  • Do we really have to worry about immutable containers of mutable values? Yes, that comes up once in a while, but it is often enough to make it worth writing const [] const T?
@romshark

This comment has been minimized.

Copy link
Author

commented Oct 3, 2018

@dsnet
getters/setters are doing just that: they copy stuff from the inner scope of the struct. You
don't want to copy everything every time, and you certainly don't want to do it manually. Writing setters, getters, cloners just for the sake of ensuring immutability is not only quite tedious but also very error-prone due to pointer aliasing, which is the scariest part actually. Copy-code tends to be rather complicated in Go, one wrong copy (like copying a pointer, or naively copying a reference type such as a slice) and you've introduced aliasing that could have terrific, non-obvious consequences. With immutable types though, having read-only aliasing is just fine because there's no mutable shared state.

Currently, the safest way of avoiding manual copying of large structs are interfaces. You could define 2 interfaces where one of them lacks the mutating methods and return interfaces from the getters only. This is an "okay" solution, but it doesn't solve internal mutability problems. In big open source projects many people are working on the code, intentions must be unambiguous, clear and precise, which they're currently not. Can you automatically ensure that the methods implementing the read-only interface do not mutate the object for sure, even after merging a pull request from an external developer who's not fully aware of your intentions? You can't! You'll have to write proper unit tests and carefully analyze each and every commit! With immutable types you declare your interface methods immutable and you can be 100% sure that any implementation of it trying to mutate the object will fail the compilation. Apart from that, interfaces aren't free, they do have a slight runtime cost due to indirection, so having an option to avoid them for performance reasons while still preserving safety is a good thing!

I always prefer to solve these kinds of problems declaratively. I declare what is mutable/immutable while the compiler does all the dirty work of making sure neither me, nor my coworkers, nor the open source contributors sending in their pull requests shoot themselves in the foot introducing bugs. Isn't this the way compiled languages should make our lives easier?

@romshark

This comment has been minimized.

Copy link
Author

commented Oct 3, 2018

@networkimprov
As section 3. of the design document clearly states: immutability by default and explicit mutability qualification through mut is preferable, but would only be possible in a backward-incompatible Go 2.x specification, which is not to be expected any time soon (AFAIK, the "Go 2" they're advertising is rather a Go 1.13+ because the folks at Google aren't big fans of breaking compatibility as it seems).

Naming conventions would break backward-compatibility. Old Go 1.x code could either stop compiling or fail at linting, which is unacceptable. This proposal aspires to preserve backward-compatibility at all cost. There's also a somewhat related question in the FAQ by the way.

@randall77

This comment has been minimized.

Copy link
Contributor

commented Oct 3, 2018

You may want to read Russ' evaluation of a read-only slices proposal. It contains a lot of the issues that this proposal should grapple with.

@dsnet

This comment has been minimized.

Copy link
Member

commented Oct 3, 2018

getters/setters are doing just that: they copy stuff from the inner scope of the struct.

But they don't have to. If the inner field is a composite type, the getter can return an opaque type that internally holds a pointer to the composite type and only provides exported read-only getter methods.

Writing setters, getters, cloners just for the sake of ensuring immutability is not only quite tedious but also very error-prone due to pointer aliasing, which is the scariest part actually

I've written several immutable APIs in this way. I absolutely agree that it is tedious, but I personally don't think it was "very error-prone" from the perspective of the API author. Pointer aliasing is not inherently the problem; it is problematic if a pointer to a non-opaque type leaks to the public API. However, I find it relatively straight-forward to review the public API and reason that it doesn't violate immutability.

Can you automatically ensure that the methods implementing the read-only interface do not mutate the object for sure, even after merging a pull request from an external developer who's not fully aware of your intentions? You can't!

Since read-only APIs are usually just getters, they are not terribly complicated such that you would accidentally mutate the object (e.g., it is not hard to review this and reason it is read-only).

In big open source projects many people are working on the code, intentions must be unambiguous, clear and precise, which they're currently not.

An opaque read-only API does make the intention clear. The lack of any setter methods is a clear signal that the user should not (and cannot) mutate anything.

Apart from that, interfaces aren't free, they do have a slight runtime cost due to indirection, so having an option to avoid them for performance reasons

Interfaces are one such implementation, but it doesn't have to be. It can be a concrete type too:

type MutableStruct struct {
    Field int
    ...
}
type ImmutableStruct struct { p *MutableStruct }
func (p ImmutableStruct) GetField() int { return p.p.Field }

There is practically no runtime cost to this as the compiler can inline all the getters as if they were nested field accesses (or slice indexes, map lookups, etc).


I am bringing the technique up not as an end-all alternative to your proposal, but more so to counter the claim that "copies are the only way to achieve immutability ... [which] degrades runtime performance". It is a legitimate approach taken today to address this problem, which the proposal seems to gloss over.

I agree that there are disadvantages to opaque APIs with read-only getters (especially with regard to their tediousness and perhaps the lack of implicit casting), but I think it would help the case of a proposal trying to add immutability to acknowledge techniques done today to work around the problem and show that the benefit of adding immutability outweighs the cost (e.g., complexity in type system and the "const poisoning" mentioned earlier).

@networkimprov

This comment has been minimized.

Copy link

commented Oct 3, 2018

Naming conventions would break backward-compatibility.

Above I suggested a go-vet switch to support a naming convention. Such a convention would be optional, permanently. As would a mut keyword. Lots of folks don't want to code that way.

There has been plenty of discussion about const-ness over the years, yet the two priorities for Go2 are error handling & generics, and code for them presumably won't land for a couple years (there is no defined schedule as yet).

@alvaroloes

This comment has been minimized.

Copy link

commented Oct 3, 2018

I love how well done the proposal is. Thank you for your hard work on this.

I have just one thought that I would like to share:
It is stated in the proposal that we can't cast an immutable var to a mutable one, as we could break the immutability. The opposite, casting a mutable var to an immutable one, can be done, which makes sense. However, in this case, we can break immutability. I know that you talk about this in section 4.5. How are constants different from immutable types?, but this could lead to very subtle and unexpected situations.

For example, let's say we have this Slice type:

type Slice []int

func (s *Slice) Add(elem const int) {
    *s = append(*s, elem)
} 

func (s const Slice) ImmutableVersion() const [] const int {
    return s
}

And then we use it like this:

slice := Slice{1,2,3,4}
immutableVersion := slice.ImmutableVersion()
// Now immutableVersion = {1,2,3,4}
slice.Add(5)
// Now immutableVersion = {1,2,3,4,5} It has changed

This behavior could be unexpected and lead to confusion, as you were guaranteed by the type system that the var immutableVersion was immutable.
This can be even worse with slices as, if the capacity is exceeded, append will allocate a new underlying array, what means that the immutableVersion won't be changed. So we don't really know if/when the immutableVersion will change.

This won't happen if the method ImmutableVersion() returns a copy.

Don't get me wrong! I love the proposal. I think it is the best one I have seen for immutability and I would like it to come true as soon as possible.

I just wanted to know the general opinion about the case I have posted.

Thanks!

@nemith

This comment has been minimized.

Copy link
Contributor

commented Oct 3, 2018

In 2.6. Immutable Interface Methods I am not sure I understand why enforcement of mutability on the interface is important. This seems more like an implementation detail and could severely limit the usefulness of interfaces if abused too much. The answer in 4.7 doesn't make much sense to me.

@rsc rsc added the Go2 label Oct 3, 2018

@romshark

This comment has been minimized.

Copy link
Author

commented Oct 3, 2018

@ianlancetaylor

The difference between C-style const and the proposed const

Is there any difference between this proposal and the use of const as a type qualifier in C, other than the logical extension to interfaces?

There is! The const in C is just confusing while the const in this proposal always targets the type on the right:

goal Go C
reassignable pointer to writable T * T Т *
reassignable pointer to read-only T * const T T const *
read-only pointer to writable T const * T T * const
read-only pointer to read-only T const * const T T const * const

In fact, C-style const is so confusing that const char * is the exact same as char const *. You also can cast const to non-const in C, which you can't in this proposal. Please do not compare the const from C with the proposed const for Go, we don't wanna do it the horrible C-way, but rather learn from its mistakes!

Const-Poisoning

If by "const-poisoning" you mean the ability to cast immutable types to their mutable counterparts then I've got good news for you: C-style const poisoning is impossible with this proposal.

IV. Mutable types can be cast to their immutable counterparts, but not the other way around.

Terminology

Pedantically, I don't particularly like using the word "immutable"

This proposal is not about functional-programming-style "immutable objects", it's about "immutable types". Immutable objects remain immutable after they're initialized while immutable types are types you can't perform mutations on. "Types" and "objects" are obviously not the same and this proposal doesn't propose immutable objects.

Immutable Reference Types

Do we really have to worry about immutable containers of mutable values? Yes, that comes up once in a while, but it is often enough to make it worth writing const [] const T?

Reference types such as pointers, slices and maps shall be no exception in the concept of immutable types (slices and maps are reference types. Yes, they're implemented by a struct but to us users they're opaque). Section 5.2.1. describes why transitive immutability is to be avoided. Basically, it makes the entire concept of immutable types useless when the developer faces a slightly more complex situation like when a reference, such as a pointer, must point to an exact mutable object. But it's the complex situations the developers need the compiler's help most! Transitive immutability will force the developer to throw immutable types out the window because they limit his/her expressiveness making it totally useless.

@networkimprov

This comment has been minimized.

Copy link

commented Oct 3, 2018

@ianlancetaylor's 2010 blog post on const: https://www.airs.com/blog/archives/428

He describes const as compiler-enforced documentation, except for variable definition where it directs the compiler to use read-only memory. (And he discusses const poisoning.)

But there is another way that const can affect generated code; a const function argument can be passed by reference instead of by value, so that a compound object (Go struct or array) isn't copied onto the stack. (Go maps and slice contents are already passed by reference.)

I'd like to see Go support that kind of const.

@ianlancetaylor

This comment has been minimized.

Copy link
Contributor

commented Oct 3, 2018

@romshark Const poisoning refers to what happens when you add const to one function, and then you have to add const to every function that it calls, and then you have to add const to every function that those functions call, etc.

Then sometimes you discover that you have to change some lower function to not use const, for perfectly valid reasons, and const poisoning refers to the problem of removing const from the entire call chain.

These aren't made up problems, they are real issues that arise in practice in large programs.

Also, let me ask the comparison with C again: is there any difference between the use of const in this proposal and the use of const in C, other than syntax?

@romshark

This comment has been minimized.

Copy link
Author

commented Oct 3, 2018

@ianlancetaylor
This is the reason Section 2.12. even exists. It's definitely true that once you've got immutable types you need to use them everywhere. This, essentially, is the price you pay for having predictability, clarity, and safety. It's kind of a stopper for the Go 1.x proposal, I agree, but in Go 2.x this must be done with immutable types by default to avoid having ambiguity from the very start and not end up with the Go 1.x problem of having to fix all libraries including the standard one.

I honestly can't imagine what "perfectly valid reasons" you need to have to, for example, make any of the lower functions called by strings.Join() not use const for the slice of strings you pass to a because a should be guaranteed to not be touched in any way neither by strings.Join() nor by any of the functions up the stack. And since immut -> mut casting is inherently forbidden I see no problems here. Once you provide a contract (API) - you either support it or you break it, not silently violate it!

Can you give us an example of when we'd suddenly discover that we actually needed mutable inputs and thus have to "remove immutability"?

There is no semantic difference between the C-style const and the proposed const. The proposed const is a better execution of the C version, but in the end, they both serve a similar purpose:

  • make the intentions of the code author clear and reliable.
  • protect the code from undesired and unexpected mutations.
@ianlancetaylor

This comment has been minimized.

Copy link
Contributor

commented Oct 3, 2018

Problematic cases happen in large, complex, programs, so there are no small examples. In terms of your proposal, the kind of thing that happens is that you start passing a map around, and there is no reason to change it, so you mark it const everywhere. Let's say it's a map of names to codes or something. Then later you realize that the names sometimes change, but you can only discover that deep in the call hierarchy. So you have to add some code there to change the map, and you have to remove const from all the callers. Obviously that is easy to nit pick, it's just an example, and, yes, I've seen this kind of thing happen in real code. In fact in C++ this is where most people reach for const_cast.

That aside, I note that you haven't replied to my memchr comment. In that regard you might want to read through #22876, which tries to address that problem through "permission genericity."

@romshark

This comment has been minimized.

Copy link
Author

commented Oct 4, 2018

@ianlancetaylor
Yes, that is a problem indeed and the "permission genericity" concept proposed by Jonathan Amsterdam in #22876 does look promising (I wonder how I missed that). I'll give it a thought, it probably makes sense to integrate the concept of immutability genericity into this proposal as well.

@beoran

This comment has been minimized.

Copy link

commented Oct 4, 2018

I appreciate the thought that went into this proposal, however I think immutability is mostly an academic concern. Seeing how complex this proposal is, I'd like to hear of some experience reports where accidental mutation actually caused serious problems in a large Go code base. In my experience, accidental mutation is a relatively rare cause of bugs in programming. Therefore I think it does not warrant the troubles of having to use const constantly, or having to constantly worry about const correctness.

@imatmati

This comment has been minimized.

Copy link

commented Oct 4, 2018

I appreciate the thought that went into this proposal, however I think immutability is mostly an academic concern. Seeing how complex this proposal is, I'd like to hear of some experience reports where accidental mutation actually caused serious problems in a large Go code base. In my experience, accidental mutation is a relatively rare cause of bugs in programming. Therefore I think it does not warrant the troubles of having to use const constantly, or having to constantly worry about const correctness.

I am more than reluctant to introduce more complex solution than the problem you're trying to solve. Go is a simple language to a certain extent, we don't need to copy other language just to make some swing.

@deanveloper

This comment has been minimized.

Copy link

commented Oct 4, 2018

I haven't seen any comments from those who've given a 👎, so here's my input.

Personally I don't like the idea behind const types. It reminds me of C's const which, IMO, was a disaster that complicated C's otherwise simple type system.

I am aware that it brings in a lot of safety, but it comes at the cost of a lot of readability. I also understand that sometimes sacrifices to readability need to be made to increase safety, but I'm not sure if this is one of them.

@bcmills

This comment has been minimized.

Copy link
Member

commented Oct 4, 2018

In fact in C++ this is where most people reach for const_cast.

In the C++ codebases I've maintained, the vast majority of uses of const_cast were in order to overload const and non-const member functions.

That technique is recommended in Effective C++ (and in this StackOverflow answer), but disrecommended in the isocpp.org core guidelines.

That seems to support the theory that const-parametricity is sufficient to avoid the need for such casts.

@ianlancetaylor

This comment has been minimized.

Copy link
Contributor

commented Oct 4, 2018

I didn't mean to imply it was the only time people used const_cast.

@bcmills

This comment has been minimized.

Copy link
Member

commented Oct 4, 2018

That does raise the problem of migration paths, though: there are widespread APIs today, such as io.Writer that accept read-write slices, and a naive approach to const in the type system would require at least one of:

  • workarounds at every call site, such as conversions through unsafe.Pointer;
  • escape hatches for calling existing (Go 1) APIs, such as (unsound) bivariant subtyping; or
  • large-scale updates to ~all existing packages.

In contrast, a dynamic analysis (such as the one in #22048, possibly only enforced when the race detector is enabled) would only affect existing APIs that actually perform unexpected writes.

@LeonineKing1199

This comment has been minimized.

Copy link

commented Oct 4, 2018

I'm not sure why you're bringing up io.Writer, @bcmills.

Mutable variables can safely be accepted as const and the API documentation for io.Writer itself attempts to convey its immutability requirements.

@jimmyfrasche

This comment has been minimized.

Copy link
Member

commented Oct 4, 2018

Languages that get this right start with immutable by default and do a lot of work behind the scenes to make everything seamless and need some syntax sugar to make it easy to use. I love immutability, but I don't think it'd really fit in to Go well.

I do want to provide my position on one of @ianlancetaylor's points, though.

[T]he memchr problem. In C the standard library memchr function, which returns a pointer to the first occurence of a character in a string, is defined as char *memchar(const char *, char). The problem is that in terms of types, if memchr is passed a const char * it should return a const char *, and if memchr is passed a char * it should return a char *. That is, it should preserve the const-ness of its first argument.

Let's say A is const char * and B is char *.

memchr(A, B) is fine.

memchr(A, A) and memchr(B, B) should both be type errors. Each should need an explicit step to transition to/from an immutable copy: something like memchr(A, mutable_copy_of(A)) and memchr(immutable_copy_of(B), B).

This is the same as if this were a Go func with the signature func memchr(string, []byte) []byte where A is a string and B is a []byte. You can't just call memchr(A, A) and have it be the same as string(memchr(A, []byte(A))).

This inevitably leads to needing (at least) two versions of everything to avoid copying everything dozens of time or not being able to use (im)mutable versions of types because a needed dependency made the choice for you.

Sometimes generics could help with that by letting you write multiple versions simultaneously, but that means making a lot of things generic that otherwise would not need to be, essentially swapping "const poisoning" with "generics poisoning".

Having freeze/unfreeze/isFrozen sidesteps this but adds a dynamic axis to a static type system. Instead of having two versions of each function you have one version that needs to cope with both at runtime.

@Dragomir-Ivanov

This comment has been minimized.

Copy link

commented Oct 4, 2018

@romshark I also had bad experience with C++ const poisoning, and it is not due to "bad design/API" but mere new features/requirements added, that need to modify something deep in the call chain, that nobody though should be modified before. And usually these features are needed for "yesterday", I did the only possible thing - const_cast right on the spot. So @ianlancetaylor concerns are very valid. One thought on all this const correctness thing: We have to declare that if something is marked as const in the current call chain, doesn't mean that it will not change by other part of the program running concurrently where they are not const types. It is just that current call chain can't change it ( unless you remove the const with something along the lines of const_cast ). Yeah, const correctness it is not a simple beast.

@networkimprov

This comment has been minimized.

Copy link

commented Nov 29, 2018

@gwd great idea! I filed an issue for that concept in the design doc repo:

romshark/Go-1-2-Proposal---Immutability#23 - Mutability qualifier limited to package

@romshark

This comment has been minimized.

Copy link
Author

commented Nov 30, 2018

@gwd the overloading of const proposed in the first revision of this document is now obsolete and we do indeed consider opt-in per-package solutions in the second revision that's currently a WiP.

I'm not yet sure whether the "everything is immutable by default in immutablity-aware packages" strategy is the way to Go because Go was and most likely will remain mutable by default. Immutability by default in immutability-aware packages might cause a lot of confusion (actually I'm not yet sure whether it will or not, but I tend to assume that it will).

The (probably) less confusing way would be to still keep everything mutable by default but make immutability-aware packages provide the mut and immut keywords which are not part of the older language specification. You then could enable those keywords using the //go:immutable compiler flag and when you want a function argument to be immutable for example you'd declare it as func PointerMatrix(a immut [][]*T).

When an old immutability-unaware package imports a newer immutability-aware package the immut and mut keywords are just ignored, but the compiler could still throw a warning when old code incorrectly uses newer code, like when you're mutating immutable global variables in immutability-unaware packages or similar.

EDIT: see the comment below

@networkimprov

This comment has been minimized.

Copy link

commented Nov 30, 2018

But you really ought to try a default-immutable proposal first to see if it flies :-)

@romshark

This comment has been minimized.

Copy link
Author

commented Nov 30, 2018

@networkimprov imagine the following case (immutability is by default)

we have a new package b which has immutability enabled:

//go:immutable
package b

var C string = "immutable"

we have an old package a which is immutability-unaware:

package a

import "b"

func Func() {
  b.C = "new value" // dangerous!
}

Since a is not aware of immutability - the above code will compile, but might throw a warning like:

.a.go:6:6 WARNING: mutating variable of an immutable type immut string

The problem is that it's not obvious for a regular Go programmer, who's not yet familiar with the concept of immutable types, that b.C must not be mutated! b.C looks just like a regular mutable string variable. If the code was not immutable by default, like this:

//go:immutable
package b

var C immut string = "immutable"

...then it'd be less confiusing because you'd take the immut qualifier into account

However, I'm not 100% sure whether or not this is truely confusing, maybe it's not if there's a warning in both the linter and the compiler?

@networkimprov

This comment has been minimized.

Copy link

commented Nov 30, 2018

Well if you import a //go:immutable package, you're writing new code. If you prefer your code not be default-immutable, let keyword immut tag references to data from the immutable package. (immut would not apply to local-package data, nor within a default-immutable package.)

// NOTE: revised in a comment below

package a

import "b"

func f() {
  v := b.C          // OK, copied
  p immut := &b.C   // OK
  var x immut *T    // OK
  x = p             // OK

  b.C = T{}         // no
  *p = T{}          // no
  p = new(T)        // no
  x immut := T{}    // no
  x immut := new(T) // no
}
@romshark

This comment has been minimized.

Copy link
Author

commented Nov 30, 2018

@networkimprov I just realized, that "immutability by default" might be dangerous! If we elude immut then older compilers will recognize b.C as a regular mutable string, which is not good at all! Chances are high you're gonna get yourself in trouble because the author of b assumed b.C will never be mutated. Mutating b.C might produce silent bugs which compile perfectly on older compilers.

It's better to make the immut keyword obligatory because older compilers will fail as immut is an unknown keyword from their point of view.

So, finally, it should rather look like this:

//go:immutable

// package b is taking advantage of the new immutability qualifiers
package b

type T struct {
  Name immut string
}

var C immut T = T{
  Name: "global",
}
// package a doesn't accept the "immut" and "mut" keywords
// but respects immutability of imported packages on newer compilers
package a

import "b"

func f() {
  /* local instance */
  l := b.T{}         // l is of type "mut b.T"
  l.Name = "foo"     // ERROR: illegal mutation of "immut string"

  /* copy */
  v := b.C           // v is of type "mut b.T"
  v.Name = "foo"     // ERROR: illegal mutation of "immut string"
  v = b.T{Name: "x"} // fine, v is mutable
  v.Name = "bar"     // ERROR: illegal mutation of "immut string"

  /* direct access */
  b.C = b.T{}        // ERROR: illegal mutation of "immut T"
  b.C.Name = ""      // ERROR: illegal mutation

  /* intermediate pointer */
  p := &b.C          // p is of type "mut * immut b.T"
  p.Name = "baz"     // ERROR: illegal mutation of "immut string"
  *p = b.T{}         // ERROR: illegal mutation of "immut T"
  p = new(T)         // fine, because the pointer is mutable ("mut * immut b.T")

  /* casting */
  var x *b.T = p     // ERROR: illegal casting "mut * immut b.T" to "mut *b.T"
}
  • compiling with an older compiler, which doesn't have a clue about //go:immutable: will fail
  • compiling with a newer compiler, which supports immutable packages: will fail due to illegal mutations and illegal casting
@networkimprov

This comment has been minimized.

Copy link

commented Nov 30, 2018

If //go:immutable doesn't stop a compiler that doesn't recognize it, then maybe

package b
immut

package b immut // alternatively
@kirillDanshin

This comment has been minimized.

Copy link

commented Nov 30, 2018

@networkimprov I like the oneline syntax, but if we scale it it can look bad:

package b immut nogc smthElse
@romshark

This comment has been minimized.

Copy link
Author

commented Nov 30, 2018

@networkimprov

  1. I personally think that a compiler flag like //go:immutable is okay for a language experiment while a proposal changing the package declaration syntax will almost certainly be rejected for obvious reasons (it's exceptional, it's kinda weird if other parameters get introduced, etc.).

  2. Also, people are used to a Go where everything's mutable by default and will probably revolt when var C T from a newer package and var C T from an older one are different types even though they look exactly the same.

If we keep Go mutable by default and just introduce the immut and mut keywords through the //go:immutable immutability experiment, then we can drop the flag in the future, when immut and mut become an inherent part of the language specification. We won't however be able to drop the flag if we make those packages immutable by default because if we elude the flag later then old untouched code won't compile!

@deanveloper

This comment has been minimized.

Copy link

commented Nov 30, 2018

Comments should never be used to indicate language changes. Go uses comments to give hints to the compiler that you want something to be treated in a certain way, but they should never cause something to fail to compile. (They're also used with cgo, but that's a whole separate thing)

Either way, from a design standpoint, a comment should not affect the language itself in any way. If we do this it'd be much better with package b immut specifier or similar.

@romshark

This comment has been minimized.

Copy link
Author

commented Dec 1, 2018

@kirillDanshin @deanveloper

package b immut nogc

looks kinda confusing, hard to read, especially if we consider adding more in the future. Argument-like parenthesis would look better IMHO:

package b (immut, nogc)

Also immut could be shortened down to mut for "mutability qualification".

@networkimprov

This comment has been minimized.

Copy link

commented Dec 1, 2018

Back to immutable-mutable package integration. I suggested immut to tag references in a mutable package to data from an immutable package; immut would not apply to local-package data, nor copies of immutable data.

@romshark then suggested immutable behavior in a default-mutable package, which I'd expect to cause the confusion he's concerned about, in #27975 (comment).

I think the following rules are both consistent and safe:

package m             // default mutable

import "i"            // default immutable

func f() {
  var x * immut i.T   // OK
  p := &i.D           // OK
  x = p               // OK

  v := i.D            // OK, copied
  v = i.T{}           // OK
  v.a = 1             // OK

  *p = i.T{}          // no
  p = new(i.T)        // no
  i.D = i.T{}         // no
  i.D.a = 1           // no
  var x immut i.T     // no
}
@networkimprov

This comment has been minimized.

Copy link

commented Dec 1, 2018

And we really ought to seek a blessing from the Go Gods to continue this discussion, because we might be wasting our fingerwork :-)

@ianlancetaylor @robpike @griesemer @rsc, any thoughts on package-specific immutability?

More above, and here: romshark/Go-1-2-Proposal---Immutability#23

@romshark

This comment has been minimized.

Copy link
Author

commented Dec 1, 2018

@networkimprov

The laws of immutability must be respected even in the scope of regular immutability-unaware packages, otherwise immutability qualification doesn't solve any problem but creates a whole bunch of new ones when seemingly immutable stuff gets mutated silently from places you'd never expect.

And here's why:

A copy isn't always a deep copy because of pointer aliasing.

package i (immut)

type T {
  S string
  F immut *T
}

func (r *T) PotentiallyMutate() {}

func (r immut *T) ReadOnly() {}

var G T = T{
  F: &T{},
}
package main

import "i"

func main() {
  g := i.G    // Shallow copy
  g.F = nil   // ERR: illegal assignment
  g.F.F = nil // ERR: illegal assignment
}

i.G.F is only shallowly copied, thus mutating g.F.F will mutate G causing all sorts of trouble, because nobody ever expected G to be mutated from the outside! Making a deep-copy of i.G.F is also not an option for 2 reasons:

  • we'll decouple the two. Any state changes like a mutation in i.G.F.S will not reflect on g
  • it's too expensive for recursive structures, especially if we do it over and over again at runtime

Even when we create our own independent instance in the scope of an immutability-unaware package we still must obey the rules:

package main

import "i"

func main() {
  m := i.T{F: &i.T{}}
  m.ReadOnly()          // OK
  m.PotentiallyMutate() // OK, because m is mutable

  m.F = nil               // ERR: illegal assignment
  m.F.PotentiallyMutate() // ERR: mutating method on immutable m.F
  m.F.ReadOnly()          // OK
}

Why? because we didn't make i.T.F immutable for no reason! There's a reason why we did it and we cannot allow main to illegaly abuse i.T in a way the author of i never intended. Also implementing the "when something originates from an immutable package then obey the rules, otherwise don't"-logic would unnecessarily complicate the compiler.


What must be illegal is: using the immut keyword in a non-immut-package. The immut and mut keywords are only available in immutability-aware packages for backward-compatibility reasons.

var x * immut i.T

The above snippet should not compile in package main. It could only compile in package main (immut)


It'd sure be great to get feedback from actual Go-team members, but I'm yet to publish the second revision of the design document covering already mentioned criticism:

As soon as I've covered these issues I'll document them, publish the second revision and ask the Go-team for further feedback. I just don't like wasting anyone's time with ill-conceived ideas and immutability isn't on their hot-fix list as it seems.

@ianlancetaylor

This comment has been minimized.

Copy link
Contributor

commented Dec 3, 2018

@networkimprov Personally I'm not a fan of having the language change on a per-package basis. That's typically the wrong granularity.

@romshark

This comment has been minimized.

Copy link
Author

commented Dec 3, 2018

@ianlancetaylor currently I know of only 3 possible ways:

  1. introduce the mut and immut keywords, potentially breaking any code that uses these names for other symbols keeping Go mutable by default.
  • pro: easiest
  • con: not so great for backward-compatibility, some older code might break due to name collisions
  • con: no experimental phase

  1. introduce the new keywords on a per-package basis with a new package-arguments syntax: package p (immut)
  • con: requires a new package declaration syntax
  • con: no experimental phase
  • con: cannot be canceled as easily as 3.

  1. introduce the new keywords on a per-package basis with a temporary //go:immut compiler-flag and call it the "Go immutability experiment". Remove the flag in the future and get to the same situation as 1. but give people time to migrate
  • pro: experimental feature with potential to become part of the language
  • con: introduces a new kind of compiler flag which rules the package scope and must be present in each file of the package

  • If we want immutability by default then 2. and 3. are the only options (except that we can't remove the flag over time, because the code from older Go packages and the newer ones - where everything's immutable by default - is naturally incompatible)
  • the check keyword proposal takes the 1. approach if I'm not mistaking, so it wouldn't be the only language feature to get introduced this way
@ianlancetaylor

This comment has been minimized.

Copy link
Contributor

commented Dec 3, 2018

We can add new keywords in new language versions where needed. See the discussion in #28221.

@beoran

This comment has been minimized.

Copy link

commented Dec 6, 2018

Immutability can today already be achieved by using structs with purely value based semantics, avoiding pointers altogether. For example:

package main

import (
	"fmt"
)

type CannotMutate struct {
	i int
	s string
}

func NewCannotMutate(i int, s string) CannotMutate {
	return CannotMutate{i, s}
}

func DoesNotMutate(data CannotMutate) CannotMutate {
	data.i++
	return data
}

func (cnm CannotMutate) String() string {
	return fmt.Sprintf("CannotMutate: %d %s", cnm.i, cnm.s)
}

var hiddenState CannotMutate = CannotMutate{42, "Can't touch this"}

func State() CannotMutate {
	return hiddenState
}


func main() {
	data1 := NewCannotMutate(7, "hello")
	fmt.Printf("Data 1: %s\n", data1.String())
	data2 := DoesNotMutate(data1)
	fmt.Printf("Data 1: %s\n", data1.String())
	fmt.Printf("Data 2: %s\n", data2.String())
	data3 := State();
	fmt.Printf("Data 3: %s\n", data3.String())
	fmt.Printf("hiddenState: %s\n", hiddenState.String())
	data4 := DoesNotMutate(data3)
	fmt.Printf("Data 3: %s\n", data3.String())
	fmt.Printf("Data 4: %s\n", data4.String())
	fmt.Printf("hiddenState: %s\n", hiddenState.String())	
}

Try it here: (https://play.golang.org/p/BJiDNdxk9N_t)
As long as you stick to this style, external packages cannot mutate your package's state, nor can they mutate your data's contents.

An immut keyword brings a lot of trouble without much additional benefit beyond sticking to a value base API as I show above. Furthermore, seeing the new rules for Go language change proposals (https://blog.golang.org/go2-here-we-come), immutability by default is definitely out of the question, since it would require a full rewrite of almost /all/ existing go code.

Such immutable API is practical and has been used in the wild frequently, see this github search:
https://github.com/search?q=immutable+go

@romshark

This comment has been minimized.

Copy link
Author

commented Dec 6, 2018

@beoran I'm sorry, but have you even read the problems section? Please read it, it's worth your time :) There were also a lot of discussions you should take into account.

If you still don't want to take your time reading then consider this: how do you want to safely handle slices then? Just hope the code does what the comments say? Copy the hell out of every slice? This proposal is about compile-time guarantees and clear definitions of APIs, again, please read it!

Immutability-by-default was one of the possible ways, and no, it would not require rewriting any existing code if it's enabled on a per-package basis! I now move away from it though for backward-compatibility and historical reasons. We don't want per-package immutability as it turns out, so we can't use immut-by-def. Also, Go1 is mutable by default, making Go2 the opposite would generate a lot of confusion. Go2 will probably have to remain mutable by default and just provide the mut and immut keywords.

@beoran

This comment has been minimized.

Copy link

commented Dec 6, 2018

@romshark

This comment has been minimized.

Copy link
Author

commented Dec 6, 2018

@beoran pointers and copies are not interchangeable, there's a reason we have them and there's sometimes a reason to use them in APIs. But it's not only public APIs, it's also package-internal code that often needs to be protected, consider the following example:

Not just public APIs, it's compiler-enforced documentation & clear intentions

package a

type Object struct {
  // uniqueID must remain immutable once the Object is created
  uniqueID string
}

func NewObject() Object {
  return Object{
    uniqueID: generateUniqueID(),
  }
}

/* lots of code here */

How do you make sure Object.uniqueID is never changed after the Object instance is created? You'll have to make sure you never allow something like obj.uniqueID = "foo" to ever happen in the scope of your package, which might be big & open source, and a lot of people could be working on it pushing their pull requests.

Instead of relying on smart humans (which is an anti-pattern in software engineering), I propose this:

type Object struct {
  // Save to be exported
  UniqueID immut string
}

You just can't make this mistake, ever. You'll never get faulty pull requests because it won't compile if anyone messed it up.

Slices

// Find guarantees to never write to a
func Find(a []string, s string) int {
  return second(a, s)
}

// somewhere in another package, promises not to change b,
// because third promised it as well (sometimes in the past)
func second(b []string, _ string) int {
  return third(b)
}

// somewhere in a third package
func third(a []string) {
  a[0] = "whoops" // third(a []string) never guaranteed to never change a
}

How do you make sure a is never changed by Find? And by "never" I really mean never, not even over time and many many iterations. Writing a comment won't help, a comment is not enforced, it's a pure claim but code might change over time (bugs might get introduced). If we assume those functions are maintained by 3 different people then chances of silently introduced bugs become very high!

Do you really want to write wrappers & interfaces for each and every slice everywhere? Sounds like tons of boilerplate and trouble which could've been avoided with immut without a single line of additional code:

func third(a immut []string)
  • There's no way the maintainer of third can break his promise
  • There's no way the maintainer of second can introduce a silent bug because third decided to change its behavior over time because a change from a immut []string to a []string is a breaking one.

Copies, copies, copies, boilerplate, boilerplate, boilerplate

Consider the following example:

type T struct {
  internal []uint64
}

// Get returns a reference to the internal slice
func (t *T) Get() []uint64 {
  return t.internal // shallow copy return
}

We can't do that, we want to avoid T.internal from being randomly mutated from the outside, it's unexported for a reason, so we'll have to copy it:

// GetCopy returns a copy to the internal slice
func (t *T) GetCopy() []uint64 {
  cp := make([]uint64, len(t.internal))
  copy(cp, t.internal)
  return cp
}

But copying is expensive, how can we optimize our API? Well.. we could implement manual iterating:

// Len returns the length of the internal slice
func (t *T) Len() int {
	return len(t.internal)
}

// At returns an item from the internal slice given its index
func (t *T) At(index int) uint64 {
	return t.internal[index]
}

Let's look a the benchmark results

goos: linux
goarch: amd64
pkg: test
BenchmarkGet-12        	 1000000	      1326 ns/op
BenchmarkGetCopy-12    	  300000	      5610 ns/op
BenchmarkIndex-12      	 1000000	      2227 ns/op
PASS
ok  	test	5.338s

https://play.golang.org/p/NNcnq8DhWJV

The iteration interface is certainly much better than copying, but it's still not as fast as it could be, so... should we make internal exported and rely on our users to never mutate it? It's dangerous, but it's fast, it's a dilema!

A dilemma that could've been solved with:

func (t *T) Get() immut []uint64 {
  return t.internal
}

Still want your boilerplate?

I could go on and on... and actually I've already covered all this (and more) in countless discussions (I hope I did). It's all about software engineering, which is what programming becomes when you add time and other programmers.

@beoran

This comment has been minimized.

Copy link

commented Dec 6, 2018

I think your first example is an example of a bad API design. If it is critical that Object is immutable, then it should be implemented in its own package 'a/object'. This has the additional benefit that it makes testing and code review easier.

As for the slice example, a slice is in essence a pointer value, and so it should not be used at all if immutability is desired. The use of a slice is a hasty optimization here.

Yes, to do immutability in Go now, we need to write a lot of boilerplate. That's a common complaint against go, look at how we don't have enums or other similar conveniences. Generics will probably solve this problem once they get implemented. But for now, go generate, or immutable data structure libraries are feasible and sufficient.

I can agree that as the compiler stands now, a value based API is less performant than a pointer based one. I consider this a compiler and runtime optimization problem. Many functional language compilers only have pure value based semantics and optimize this splendidly. Go should also do that, but that is a different issue.

As I see it the main benefit of the immut keyword would be performance. The immut keyword would tell the compiler to pass data by pointer or slice, but to also give the passed argument value semantics. While I see the appeal of that, I feel it doesn't weigh up against the added complexity of the language.

Particularly, the function argument and result covariance problem leads to having to either having write everything function twice, once with mutable and once with immutable arguments and results, or having to do mutable/immutable casts all over the place, much like with const in C.

I think I can see where you are coming from, but my experience with immutability is different from yours. I think we will have to agree to disagree, and let the go authors weigh the arguments and decide.

@rosun82

This comment has been minimized.

Copy link

commented Dec 28, 2018

Nice document; clearly you spent a while on it. I only briefly glanced over it.

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.

Not exactly the only way. An alternative approach is to have an opaque type with only exported methods that provide read-only access, which is how reflect.Type achieves immutability. The v2 protobuf reflection API also takes this approach. It has other downsides (like needing to manually create methods for each read-only operation), but pointing out that there are non-copy approaches to immutability.

Well, I guess this does not quite work for nested protos

@vincent-163

This comment has been minimized.

Copy link

commented Feb 9, 2019

I would like to point out one more use of immutable type: as map keys.

Among all the comparable types available in go, there are only two variable-length types: interface{} and string, where string is essentially an immutable []byte. The only way to use variable-length slices of arbitrary value type as a map key is to convert them into string. Immutable types allow us to replace the byte in string with arbitrary value types and use pointers instead of value types in map keys.

@dsnet

This comment has been minimized.

Copy link
Member

commented Feb 9, 2019

I would like to point out one more use of immutable type: as map keys.

While this proposal uses the term "immutable", it is more accurately describing a "read-only" view of formerly mutable values.

On the other hand, map keys need true immutability, which is a more restrictive model. Not only do we need the map key to be "read-only", but we also need to guarantee globally that there are no mutable views of that value (lest the value changes due to remote side-effects, causing all sorts of buggy map behavior).

@go101

This comment has been minimized.

Copy link

commented Feb 9, 2019

Currently, slice/map/function values can't be used as map keys is not because they are not immutable, it is just because different people have different views on how these values should be compared. To avoid the confusions caused by different views, Go forbids comparing them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
You can’t perform that action at this time.