-
Notifications
You must be signed in to change notification settings - Fork 17.7k
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: Go2: read-only and immutability #29192
Comments
I'm personally a fan of keeping read-only-ness as a property of function parameters rather than of types as a whole. We lose a little bit of flexibility but it makes sure that the Go type system stays nice and simple. That being said, while I'm not exactly a huge fan, I'm not opposed to this either. I think some of it gets a bit complicated when we get into how these interact with interfaces. It seems to mostly be caused by polluting the simplicity of the Go type system. I think it's quite clear what |
That would be 'has a method Foo that doesn't change the receiver'. (updated) The point being, removing just |
Oh I'm aware it's needed, I just don't think it's clear. It looks like the For instance this may look confusing to someone:
If you don't know what
But either way it's just syntax, so if there is a better way to do it, it should be easily changable. Doesn't hurt the underlying concept behind the proposal. |
Also by this, I didn't only mean with the example I gave. Just the general logic (which is very sound if I may add, just confusing) behind which types can implement what interfaces can get very confusing, and as I said, this is because by adding this feature, we would be adding a layer of complexity on Go's type system. Again I'm not super against this proposal or anything, just stating my main criticisms. |
@JavierZunzunegui |
@go101 thanks for spotting, updated above |
I'm not clear on how this works for interfaces. Can I store an ro-qualified value into an ordinary interface type? The comment that anything is permitted in a How does this work with the reflect package? As you say, this is quite similar to #22876. Is there a reason to discuss it separately? |
As the proposal stands, no (assuming ordinary interface = 'has some non-ro receiver method'). It is allowed for interfaces where all methods have Reading between the lines in your question though, you may have a point that restriction is excessive. Instead the correct rule may be: I will double check this statement is correct later today when I have more time and update the proposal accordingly. (edited) |
Yes, you must assert to the correct of |
Have you got a more specific question with regards to reflect? I have tried to explain in the doc, particularly in Interface type assertion and The reflect package, not explicitly mentioned in the doc, would have to be updated to preserve this |
I still think there are still significant differences between the two, particularly my proposal blurs in some regards the distinction between I think Jonathan himself states his proposal is more than anything a stepping stone towards the right direction. In this one I try to build on it and address the issues the community saw with his. |
Can you expand on how this proposal avoids const-poisoning? It seems to me that it is necessary to use the |
Functions can be written with the most restrictive inputs with regards to |
Following my latest edit to the proposal (non- A type FooIface interface {
Bar()
ro RoBar()
}
type FooType struct {
i FooIface
}
var x = FooType{i: ...}
x.i.Bar() // legal
x.i.RoBar() // legal
var rx = ro x
rx.i.Bar() // illegal, Bar is not `ro` in `FooIface`
rx.i.RoBar() // legal, RoBar is `ro` in `FooIface` Additionally, |
This does not fix the This also becomes a problem when using libraries, especially ones that were written in Go 1. I would not be able to use my |
I don't think the issue you are describing is What you are saying is that to migrate your function to See the Migration section on the proposal. |
From this blog post (which is coincidentally written by Ian):
What this is describing is that once you want to use it in a single place, you need to use it everywhere. We are already starting to see the same thing with "context poisoning", where we see If you want to mitigate const-poisoning, you need to offer some way to pass the data from a variable that was originally |
@deanveloper I still think you are seeing poisoning in the wrong way. "context poisoning" is very real because it requires the changes to be made up the stack - the context must be passed to your function, which requires the caller to require it from its caller, etc. all the way up. The context case does not require the propagation down the stack (you can stop passing the context to lower functions when it is no longer needed).
Following the point above, |
My bad, when I referred to context poisoning you are correct that context traverses up the stack. But in order to make full use of context, it must also traverse down the stack as far as it can if your API was not originally designed to support it.
It depends on your perspective here. In terms of migration, it cannot be done gradually. If I want to update my library to use On a separate note - we'd also want to update the standard library interfaces, yes? Something like Other things we'd want to update but can't:
There really are a lot of interfaces that should be moved over, but can't be for backward compatability reasons. I think it's a mistake to simply gloss over this. |
It can be done somewhat gradually - update implementations before interfaces, update only some methods first, update dependency libraries before your library itself... No challenge on the
Yes, and your examples are valid. Short answer is we would, over some time, introduce all these as breaking changes (go2 surely has some tolerance to breaking changes!). Again, it doesn't have to be done in one massive, all-breaking change but can be done gradually - say Also worth mentioning I think another important interface to update would be type error interface {
ro Error() string // ro receiver
} |
I do still feel that this approach is vulnerable to const-poisoning. One particular aspect of const-poisoning is that in practice, as the program changes, you sometimes need to change a value that was marked as |
Yes, but then again removing Writing bad code or carelessly designed libraries is a bigger problem than read-only alone. My personal opinion is almost any feature can potentially be mis-used, limiting a language by that principle would leave us with a no features at all. Also worth highlighting again the potential significance of read-only (not this proposal in particular) towards bringing about other higher level features in the future (such as data-race freedom as @jba argues in his document). |
To summarize, I'm saying that practical experience shows that type qualifiers are painful in large programs. You are saying that people should adopt better programming practices. I think we are both right. But as a general rule the development of the Go language is guided more by practical experience than by aspirational hopes. |
True, but perhaps I should have clarified what I mean by promoting good practices. If good As a hypothetical example, the tool could discourage the |
To summarize myself, acknowledging the risks of |
Earlier I asked whether this could just be discussed with #22876. You suggested that this proposal was better than that one with regard to const-poisoning. Why is that? To me they same approximately the same in that regard. |
func foo() {
x := ro []int{}
bar(x)
}
func bar(ro []int) {...} // removing ro here breaks under both proposals
func foo() {
x := []int{} // non-ro
func(f func([]int) {
f(x)
}(bar)
}
func bar([]int) {...} // adding ro here is OK in this proposal, not in the other
// similarly (and probably more relevant) for interface implementation From a poisoning perspective I think the difference can be summarized as: |
Would it be correct to say that in this proposal there is an implicit conversion from That seems like a modification that could be discussed in the context of #22876. It is an improvement but it doesn't seem very different. |
If you drop permission genericity, how do you handle the problem it addresses? For example, how would you write
|
Yes, also from type Foo interface {
Bar(T)
}
type foo struct {}
func (f foo) Bar(ro T) {...}
var _ Foo = foo{} // foo implements Foo despite not matching in ro
Sure. These changes are the main distinction between the proposals, of course if they are added to the original they can be discussed there. I discuss these in more detail in relaxed typing rules for |
With two separate methods (
I don't think these situations are common.
Functions as variables with Also as you have already highlighted in Subsumed by Generics, On the more opinionated side, I see the purpose you want to address with |
I think the need for
These are not "mainly getters or small functions". |
The above can do without // common private auxiliar method
func trim(s ro []byte, cutset string) (int, int) {...}
// exactly as before
func Trim(s []byte, cutset string) []byte {from, to := trim(s, cutset); return s[from:to]}
// We should agree on a standard for ro-version of such functions - suffixed by 'Ro' for example
func TrimRo(s ro []byte, cutset string) ro []byte {from, to := trim(s, cutset); return s[from:to]}
While of course we have new functions with separate names, I actually find this positive - go is very explicit and not introducing genericity (even in this very limited form) keeps the language easy to understand.
They can be seen as small functions wrapping a larger, private, common function. Either way, for the few situations when there is no easy implementable common function to call or perhaps performance-wise a different approach is preferred, a simple code generation tool should be developed that can easily generate 2 versions out of one. Does this not make |
It's also noteworthy that |
I'm going to go ahead and close this in favor of #22876. While this one is different, the differences are small, and we can discuss the ideas here on that issue. |
Abstract
This document presents a solution on how to introduce read only and immutability support in go2. It is split into two parts:
This document and it's ideas build up on the Go 2: read-only types proposal by Jonathan Amsterdam, which is referred to as 'the original' throughout. It focuses on the two points above while deliberately omitting the more general questions 'why is read-only needed?', 'how is read-only different to immutability?', 'why a read-only and not an immutable keyword?', etc. These are discussed in the original proposal and follow up comments and don't need to be repeated here. I encourage anyone reading this proposal to also look at this previous one for additional context, although this one should be understandable on its own.
Other posts from which I may borrow or get inspiration are Evaluation of read-only slices, Go - Immutability or Why Const Sucks.
Language changes
The
ro
keywordThe fundamental change in this proposal is the
ro
(read-only) keyword, taken in almost the same form as in the original proposal. Some highlights that still hold:On the other hand, the ideas around 'Permission Genericity' in the original proposal are dropped (as well as the
ro?
syntax).The meaning of
ro
applied to interfaces is also subtly changed. In the original it is described as:In this proposal the meaning is slightly different - for an interface
I
,ro I
contains aro T
forT
implementingI
. If typeT
implementsI
, then typero T
implementsro I
, and vice versa.(edited)
Additionally, if
T
implementsI
and all methods inI
have aro
receiver inT
, thenro T
also implementsI
.Anonymous functions are not mentioned in the original proposal, but this one regards them as a write operation and therefore not callable in
ro
form.relaxed typing rules for
ro
qualifiersThe introduction of the
ro
keyword is intended to bring all the compile-time read-only guarantees at the minimum cost to developers. To achieve it, this proposal departs from the original one and establishes that the typing rules forro
-qualified types are somewhat relaxed, as in many contexts they are interchangeable with their non-ro
form. Conceptually,ro
-qualifying introduces compile time restriction on what operations are allowed but are largely indistinguishable at runtime.It is a given a
T
may be automatically converted toro T
, but not in reverse. Similarly special allowances are introduced for functions and methods, not otherwise allowed in go.In more detail:
Any
ro
-qualified argument in a function may be given a non-ro
argument:Any method requiring a
ro
-qualified receiver may be called on a non-ro
receiver:Any function may be converted to another similar function with some
ro
-qualifying argument(s) removed:Any method with a
ro
receiver or argument may implement a method without it:Similar rules apply to return values - a function or method with a non-
ro
return value can be converted or used to implement one where the return value isro
-qualified. The same rules apply to maps, arrays, slices, channels, i.e.[]X
may be used as an argument to[]ro X
, etc. Overall, the principle is that inputs may be relaxed to permit more general forms (non-ro
), and outputs may be tightened to produce more specific forms (ro
).By having
ro
-qualifying not produce fully independent types the need for duplicate implementations (ro
and non-ro
forms) is averted and the read-only can be supported with very little additional complexity or work by the developer.Note this is not overloading as only a single function or method may exist with a given name, and the same implementation will be used regardless of the
ro
-ness of the arguments.To support this language changes the linker must be relaxed to allow linking when the function types differ only in
ro
-ness as described above.Interface type assertion and
ro
Fundamentally, the above
ro
syntax allowing seamless conversions aroundro
-ness relies on the runtime form of a typeX
being identical to that ofro X
. This is trivially true for non-interface types, as in the go model and unlike many other languages the type is not embedded in their runtime value. This can even be trivially extended to interfaces with regards to methods being called - calling a method on aro I
can be implemented identically to calling the same method onI
. Theitab
can be reused and the same function will be called regardless, while non-ro
receiver methods can't be called in the first place so need no special handling. However, type assertion does not immediately follow. Consider the below:To satisfy this, asserting of
ro
-qualified interfaces to non-ro
qualified struct is illegal. When asserting aro I
to aro
-qualified structro X
the the runtime must confirm the runtime type within the interface isX
, even though it is returning aro X
. For example:The assertion of
ro
-interfaces to otherro
-interfaces is fundamentally the same as that of non-ro
interfaces, just inro
space:(edited)
There is also the case
ro T
implementingI
(which occurs whenT
hasro
-receivers on all methods required byI
), which meansI
is implemented by both thero
and non-ro
form. The fundamental property they must guarantee is that aro T
assigned to one suchI
may not be asserted back to aT
, nor be asserted to any interface aro T
would not satisfy. Since bothT
andro T
may be contained by such anI
and yet the distinction must be made by the runtime, they must be represented differently within the interface's type value at runtime and interpreted correctly while asserting. For example:To support this language changes, the runtime must be modified to correctly implement this new type asserting rules.
Remarks
The empty interface
The empty interface
interface{}
still matches everything and can always be asserted back to the exact type that was assigned to it. No change in that respect, it can still be used as a reversible wildcard type match.Migration
(edited)
These changes, in it's rawest form, are backwards compatible in that existing go code will work for it.
However many methods and types (and some interfaces, though those may be rarer) should be modified to reflect the new
ro
functionality. A key aspect though is that they could be migrated somewhat gradually with modest (if any) breaking changes at each step. In particular note that there is no cost in making the arguments of functionsro
when not in breach of read-only rules in the first place (including receivers for methods). This way, the methods and functions lowest in the stack can be migrated first, and then those relying on those, etc. gradually progressing up the stack. Interfaces will not normally require an update (asro
types may implement non-ro
interfaces), though in some cases they may want to be updated regardless (such as for immutability purposes), although that will likely result in backward incompatible changes.Code relying on type assertion may also need re-visiting given interfaces formerly using
T
may be migrated to bero T
instead and assertion onT
will no longer work.The case for mutable keyword
Adding
ro
can be a very limiting restriction and an exception to the rule may be wanted, such as amut
(mutable) keyword. This is done in some other languages with similar features. I believe adding this is a bad idea but it is worth debating given its implication for establishing best practices aroundro
.ro []ro T
vsro []T
For slices I have taken the
ro
syntax of the original proposal, namely[]ro T
to mean 'slice of read-only Ts' andro []T
to mean 'read-only slice of read-only Ts'. Alternatively,ro []ro T
could take that meaning, andro []T
becomes 'read-only slice of Ts'. While more verbose, this syntax is more expressive and powerful. I believe either this or comparable solutions should be explored, and possibly also for maps and arrays.Immutability
Transparent feature on top of
ro
Consider the following:
IF the compiler can assert all references to a
ro
variable are alsoro
themselves, it can treat the variable as immutableNote here the reference to compiler - it is not the developer who decides if a
ro
variable is, or even might be, immutable, but the compiler. The immutable property is completely transparent to developers and is irrelevant in all other regards other than performance - the logic of a method is identical regardless. To achieve this, the compiler must effectively do escape analysis onro
's - any nonro
-escapingro
variable can be treated as immutable, but one thatro
-escapes can't. This is very similar to go's heap escape analysis - whether a variable ends up in heap or stack - which is up to the compiler and irrelevant for all (non-performance) purposes to the developer.In other languages immutability often means the memory is only written to once (during initialisation) and remains unchanged until reclaimed (
const
in C,final
in java, etc). This is not the case in this proposal - non-immutable memory may become immutable after it has been initialised or even modified, and mutablero
variables may coexist and reference the same memory as immutablero
variables (though no non-ro
variables). In this sense immutablero
s are not likeconst
(in the current go sense, not referring to C-style const).Initialisation
Initialising immutable
ro
s (viaro
return statement)`:Initialising immutable
ro
s (via non-ro
return statement and casting afterwards):Implementation
As stated before,
ro
is primarily a means of restricting what operations are allowed, but is virtually irrelevant at runtime. Immutability is the exact opposite, it is transparent to the developer but modifies how the code is executed. For this reason, whilero
-qualified types are separate types, immutability is not. Instead it is an optimisation built into the binary, whereby function calls with inputs known by the compiler to be immutable may be linked to alternative, optimized implementations of the same function. This is similar to overloading in that a single function may have multiple implementations, but differs in that it is not the developer but the compiler that picks which one is used.I am not certain my understanding about the go runtime, compiler and linker are entirely correct here, please share if you know better.
Some changes to both the compiler and the linker would be needed to support this 'immutability from read-only' feature'. In particular methods with
ro
inputs (methods that have somero
argument or receiver) would require the compiler creating multiple implementations, potentially one for eachro
combination. It would then be up to the linker to decide which is called based on the immutability of the arguments and target (as known at compile/link time). For example:would be compiled in 2 separate ways, identical in all ways except the immutable one may include the additional optimisations:
It would be up to the linker to link to the right one:
This property must extend to interfaces. There will be nothing in the runtime value of an interface that states it's immutability state, hence the compiler/linker must be the one that decides again. I think this can be done by modifying the use of itab (or a similar new type, say
itab2
) to support not just multiple types but also multiple mutability-status on these types. When aro
input method is called on an interface the type resolution getitab (or a similar new method, saygetitab2
) will be called with some additional argument that denotes it's immutability state,imState
. That way it can resolve the correct method to call, including immutability status. For example:Note that the below only has one
ro
argument, but of course methods may have multiple ones, and the receiver itself may bero
, sogetitab2
must support different values forimState
. How best to define those I leave as a future task and should be explored more carefully.Remarks
How meaningful are the performance gains?
The purpose of immutability is to provide performance gains by avoiding unnecessary memory reads. How significant those gains are is not obvious, though. It should be investigated before commiting to this changes.
Binary bloating
If all the above changes are implemented, these may have the unintended consequence of bloating the binary as functions may have multiple implementations. Immutability is, however, at the compiler/linker's discretion and so it may decide to drop immutable implementations of methods that do not get called often or are otherwise large and provide little gain, and instead use the mutable forms.
Compile time impact
Build time of individual packages may be negatively affected as multiple implementations may need to be built for a single method, as well as an additional step of escape analysis of
ro
. Longer build time for binaries will inevitably follow.Benchmarking and immutability
Given immutability is transparent to the developer but improves performance, it may be a double edge sword when running benchmarks. It is possible for a benchmark to be run on immutable inputs but then the binary does not (or vice-versa), present misleading results with little visibility to the developer.
Some risk mitigation best practices should be explored.
Immutable parameters
So far immutability has covered arguments to functions, but it should also cover parameters held within other types. For example, in the below, can parameter
t
be considered immutable?It is generally impossible for the compiler to identify where such
X
was initialised or modified, and therefore must assume the parameter is mutable. An exception can be made when all writes to such parameter are known to set immutable values, in which case the parameter can be considered immutable. This can only realistically be done for private members, and even then while the parameter may have an immutable value, it may still be changed for another immutable value. For example:This makes the immutability of parameters a fairly limited property. One way to make it more powerful would be to allow
ro
to be applied to parameters in the following way:For reference, this is similar to 'avoiding modifications to values, not variables' mentioned in the original proposal, but applied to parameters not variables.
Although this would also require restrictions on how dereference is done in go, i.e.
This would be a significant change to the language with many breaking changes, so should not be seen as a requirement of this proposal.
Immutability Check
There may be situations when the runtime wants to validate the immutability state of a variable. A public method may be added to the runtime package for this purpose:
The special thing about this method is that, while for all other methods immutability is considered merely a performance feature, in this the output actually changes. The mutable form of the function returns false, the immutable true.
This allows for the syntax below:
Which in turns gives the option to actually enforce immutability to any given API (at the cost of deep coping in the inputs aren't immutable). Also note that the output of
IsImmutable
will be known at compile time anyways, so a smart compiler will inline it and simplify methods likeToImmutable
.For the same reason it could be added as a line directive which will fail compilation when it doesn't hold, say:
This could in turn provide the same (if more verbose) benefits of other proposals introducing immutable types. While I would make the case that this directive shouldn't be abused, it does ensure that there is support for it for the few times immutability is a serious concern for an API.
string
vsro []byte
An immutable
ro []byte
is almost identical to astring
, and opens up for conversion between one and the other without any need for memory allocation and deep copying:This makes the conversion between
string
andro []byte
effectively free, and that fromro []byte
tostring
potentially free, but depends on context.Note this exact comparison is discussed extensively in Evaluation of read-only slices
Conclusion
Introducing read-only types to go2 has multiple advantages for go development in the form of clearer APIs and safer code. I believe the performance gains of immutability is also a feature of read-only types, even if it hadn't been recognised as such before. The ideas introduced or expanded in this proposal are not complete, detailed or final, but target much of the concerns from the community and go project maintainers around introducing read-only types. Overall I hope this will lead to new discussion and maybe tilt the balance towards having read-only support added to go2.
The text was updated successfully, but these errors were encountered: