Hexbyte  Hacker News  Computers golang/proposal

Hexbyte Hacker News Computers golang/proposal

Hexbyte Hacker News Computers

Author: Ian Lance Taylor

Last update: October 15, 2018

Hexbyte Hacker News Computers Abstract

A proposal for how to make incompatible changes from Go 1 to Go 2
while breaking as little as possible.

Hexbyte Hacker News Computers Background

Currently the Go language and standard libraries are covered by the
Go 1 compatibility guarantee.
The goal of that document was to promise that new releases of Go would
not break existing working programs.

Among the goals for the Go 2 process is to consider changes to the
language and standard libraries that will break the guarantee.
Since Go is used in a distributed open source environment, we cannot
rely on a flag
day
.
We must permit the interoperation of different packages written using
different versions of Go.

Every language goes through version transitions.
As background, here are some notes on what other languages have done.
Feel free to skip the rest of this section.

C

C language versions are driven by the ISO standardization process.
C language development has paid close attention to backward
compatibility.
After the first ISO standard, C90, every subsequent standard has
maintained strict backward compatibility.
Where new keywords have been introduced, they are introduced in a
namespace reserved by C90 (an underscore followed by an uppercase
ASCII letter) and are made more accessible via a #define macro in a
header file that did not previously exist (examples are _Complex,
defined as complex in , and _Bool, defined as bool
in ).
None of the basic language semantics defined in C90 have changed.

In addition, most C compilers provide options to define precisely
which version of the C standard the code should be compiled for (for
example, -std=c90).
Most standard library implementations support feature macros that may
be #define’d before including the header files to specify exactly
which version of the library should be provided (for example,
_ISOC99_SOURCE).
While these features have had bugs, they are fairly reliable and are
widely used.

A key feature of these options is that code compiled at different
language/library versions can in general all be linked together and
work as expected.

The first standard, C90, did introduce breaking changes to the
previous C language implementations, known informally as K&R C.
New keywords were introduced, such as volatile (actually that might
have been the only new keyword in C90).
The precise implementation of integer promotion in integer expressions
changed from unsigned-preserving to value-preserving.
Fortunately it was easy to detect code using the new keywords due to
compilation errors, and easy to adjust that code.
The change in integer promotion actually made it less surprising to
naive users, and experienced users mostly used explicit casts to
ensure portability among systems with different integer sizes, so
while there was no automatic detection of problems not much code broke
in practice.

There were also some irritating changes.
C90 introduced trigraphs, which changed the behavior of some string
constants.
Compilers adapted with options like -no-trigraphs and -Wtrigraphs.

More seriously, C90 introduced the notion of undefined behavior, and
declared that programs that invoked undefined behavior might take
any action.
In K&R C, the cases that C90 described as undefined behavior were
mostly treated as what C90 called implementation-defined behavior: the
program would take some non-portable but predictable action.
Compiler writers absorbed the notion of undefined behavior, and
started writing optimizations that assumed that the behavior would not
occur.
This caused effects that surprised people not fluent in the C
standard.
I won’t go into the details here, but one example of this (from my
blog) is signed overflow.

C of course continues to be the preferred language for kernel
development and the glue language of the computing industry.
Though it has been partially replaced by newer languages, this is not
because of any choices made by new versions of C.

The lessons I see here are:

  • Backward compatibility matters.
  • Breaking compatibility in small ways is OK, as long as people can
    spot the breakages through compiler options or compiler errors.
  • Compiler options to select specific language/library versions are
    useful, provided code compiled using different options can be linked
    together.
  • Unlimited undefined behavior is confusing for users.

C++

C++ language versions are also now driven by the ISO standardization process.
Like C, C++ pays close attention to backward compatibility.
C++ has been historically more free with adding new keywords (there
are 10 new keywords in C++11).
This works out OK because the newer keywords tend to be relatively
long (constexpr, nullptr, static_assert) and compilation errors
make it easy to find code using the new keywords as identifiers.

C++ uses the same sorts of options for specifying the standard version
for language and libraries as are found in C.
It suffers from the same sorts of problems as C with regard to
undefined behavior.

An example of a breaking change in C++ was the change in the scope of
a variable declared in the initialization statement of a for loop.
In the pre-standard versions of C++, the scope of the variable
extended to the end of the enclosing block, as though it were declared
immediately before the for loop.
During the development of the first C++ standard, C++98, this was
changed so that the scope was only within the for loop itself.
Compilers adapted by introducing options like -ffor-scope so that
users could control the expected scope of the variable (for a period
of time, when compiling with neither -ffor-scope nor
-fno-for-scope, the GCC compiler used the old scope but warned about
any code that relied on it).

Despite the relatively strong backward compatibility, code written in
new versions of C++, like C++11, tends to have a very different feel
than code written in older versions of C++.
This is because styles have changed to use new language and library
features.
Raw pointers are less commonly used, range loops are used rather than
standard iterator patterns, new concepts like rvalue references and
move semantics are used widely, and so forth.
People familiar with older versions of C++ can struggle to understand
code written in new versions.

C++ is of course an enormously popular language, and the ongoing
language revision process has not harmed its popularity.

Besides the lessons from C, I would add:

  • A new version may have a very different feel while remaining
    backward compatible.

Java

I know less about Java than about the other languages I discuss, so
there may be more errors here and there are certainly more biases.

Java is largely backward compatible at the byte-code level, meaning
that Java version N+1 libraries can call code written in, and
compiled by, Java version N (and N-1, N-2, and so forth).
Java source code is also mostly backward compatible, although they do
add new keywords from time to time.

The Java documentation is very detailed about potential compatibility
issues when moving from one release to another.

The Java standard library is enormous, and new packages are added at
each new release.
Packages are also deprecated from time to time.
Using a deprecated package will cause a warning at compile time (the
warning may be turned off), and after a few releases the deprecated
package will be removed (at least in theory).

Java does not seem to have many backward compatibility problems.
The problems are centered on the JVM: an older JVM generally will not
run newer releases, so you have to make sure that your JVM is at least
as new as that required by the newest library you want to use.

Java arguably has something of a forward compatibility problem in
that JVM bytecodes present a higher level interface than that of a
CPU, and that makes it harder to introduce new features that cannot
be directly represented using the existing bytecodes.

This forward compatibility problem is part of the reason that Java
generics use type erasure.
Changing the definition of existing bytecodes would have broken
existing programs that had already been compiled into bytecode.
Extending bytecodes to support generic types would have required a
large number of additional bytecodes to be defined.

This forward compatibility problem, to the extent that it is a
problem, does not exist for Go.
Since Go compiles to machine code, and implements all required run
time checks by generating additional machine code, there is no similar
forward compatibility issue.

But, in general:

  • Be aware of how compatibility issues may restrict future changes.

Python

Python 3.0 (also known as Python 3000) started development in 2006 and
was initially released in 2008.
In 2018 the transition is still incomplete.
Some people continue to use Python 2.7 (released in 2010).
This is not a path we want to emulate for Go 2.

The main reason for this slow transition appears to be lack of
backward compatibility.
Python 3.0 was intentionally incompatible with earlier versions of
Python.
Notably, print was changed from a statement to a function, and
strings were changed to use Unicode.
Python is often used in conjunction with C code, and the latter change
meant that any code that passed strings from Python to C required
tweaking the C code.

Because Python is an interpreted language, and because there is no
backward compatibility, it is impossible to mix Python 2 and Python
3 code in the same program.
This means that for a typical program that uses a range of libraries,
each of those libraries must be converted to Python 3 before the
program can be converted.
Since programs are in various states of conversion, libraries must
support Python 2 and 3 simultaneously.

Python supports statements of the form from __future__ import FEATURE.
A statement like this changes the interpretation of the rest of the
file in some way.
For example, from __future__ import print_function changes print
from a statement (as in Python 2) to a function (as in Python 3).
This can be used to take incremental steps toward new language
versions, and to make it easier to share the same code among different
language versions.

So, we knew it already, but:

  • Backward compatibility is essential.
  • Compatibility of the interface to other languages is important.
  • Upgrading to a new version is limited by the version that your
    libraries support.

Perl

The Perl 6 development process began in 2000.
The first stable version of the Perl 6 spec was announced in 2015.
This is not a path we want to emulate for Go 2.

There are many reasons for this slow path.
Perl 6 was intentionally not backward compatible: it was meant to fix
warts in the language.
Perl 6 was intended to be represented by a spec rather than, as with
previous versions of Perl, an implementation.
Perl 6 started with a set of change proposals, but then continued to
evolve over time, and then evolve some more.

Perl supports use feature which is similar to Python’s from __future__ import.
It changes the interpretation of the rest of the file to use a
specified new language feature.

  • Don’t be Perl 6.
  • Set and meet deadlines.
  • Don’t change everything at once.

Hexbyte Hacker News Computers Proposal

Language changes

Pedantically speaking, we must have a way to speak about specific
language versions.
Each change to the Go language first appears in a Go release.
We will use Go release numbers to define language versions.
That is the only reasonable choice, but it can be confusing because
standard library changes are also associated with Go release numbers.
When thinking about compatibility, it will be necessary to
conceptually separate the Go language version from the standard
library version.

As an example of a specific change, type aliases were first available
in Go language version 1.9.
Type aliases were an example of a backward compatible language change.
All code written in Go language versions 1.0 through 1.8 continued to
work the same way with Go language 1.9.
Code using type aliases requires Go language 1.9 or later.

Language additions

Type aliases are an example of an addition to the language.
Code using the type alias syntax type A = B did not compile with Go
versions before 1.9.

Type aliases, and other backward compatible changes since Go 1.0, show
us that for additions to the language it is not necessary for packages
to explicitly declare the minimum language version that they require.
Some packages changed to use type aliases.
When such a package was compiled with Go 1.8 tools, the package failed
to compile.
The package author can simply say: upgrade to Go 1.9, or downgrade to
an earlier version of the package.
None of the Go tools need to know about this requirement; it’s implied
by the failure to compile with older versions of the tools.

It’s true of course that programmers need to understand language
additions, but the the tooling does not.
Neither the Go 1.8 tools nor the Go 1.9 tools need to explicitly know
that type aliases were added in Go 1.9, other than in the limited
sense that the Go 1.9 compiler will compile type aliases and the Go
1.8 compiler will not.
That said, the possibility of specifying a minimum language version to
get better error messages for unsupported language features is
discussed below.

Language removals

We must also consider language changes that simply remove features
from the language.
For example, issue 3939 proposes that
we remove the conversion string(i) for an integer value i.
If we make this change in, say, Go version 1.20, then packages that
use this syntax will stop compiling in Go 1.20.
(If you prefer to restrict backward incompatible changes to new major
versions, then replace 1.20 by 2.0 in this discussion; the problem
remains the same.)

In this case, packages using the old syntax have no simple recourse.
While we can provide tooling to convert pre-1.20 code into working
1.20 code, we can’t force package authors to run those tools.
Some packages may be unmaintained but still useful.
Some organizations may want to upgrade to 1.20 without having to
requalify the versions of packages that they rely on.
Some package authors may want to use 1.20 even though their packages
now break, but do not have time to modify their package.

These scenarios suggest that we need a mechanism to specify the
maximum version of the Go language with which a package can be built.

Importantly, specifying the maximum version of the Go language should
not be taken to imply the maximum version of the Go tools.
The Go compiler released with Go version 1.20 must be able to build
packages using Go language 1.19.
This can be done by adding an option to cmd/compile (and, if
necessary, cmd/asm and cmd/link) along the lines of the -std option
supported by C compilers.
When cmd/compile sees the option, perhaps -lang=go1.19, it will
compile the code using the Go 1.19 syntax.

This requires cmd/compile to support all previous versions, one way or
another.
If supporting old syntaxes proves to be troublesome, the -lang
option could perhaps be implemented by passing the code through a
convertor from the old version to the current.
That would keep support of old versions out of cmd/compile proper, and
the convertor could be useful for people who want to update their
code.
But it is unlikely that supporting old language versions will be a
significant problem.

Naturally, even though the package is built with the language version
1.19 syntax, it must in other respects be a 1.20 package: it must link
with 1.20 code, be able to call and be called by 1.20 code, and so
forth.

The go tool will need to know the maximum language version so that it
knows how to invoke cmd/compile.
Assuming we continue with the modules experiment, the logical place
for this information is the go.mod file.
The go.mod file for a module M can specify the maximum language
version for the packages that it defines.
This would be honored when M is downloaded as a dependency by some
other module.

The maximum language version is not a minimum language version.
If a module require features in language 1.19, but can be built with
1.20, we can say that the maximum language version is 1.20.
If we build with Go release 1.19, we will see that we are at less than
the maximum, and simply build with language version 1.19.
Maximum language versions greater than that supported by the current
tools can simply be ignored.
If we later build with Go release 1.21, we will build the module with
-lang=go1.20.

This means that the tools can set the maximum language version
automatically.
When we use Go release 1.30 to release a module, we can mark the
module as having maximum language version 1.30.
All users of the module will see this maximum version and do the right
thing.

This implies that we will have to support old versions of the language
indefinitely.
If we remove a language feature after version 1.25, version 1.26 and
all later versions will still have to support that feature if invoked
with the -lang=go1.25 option (or -lang=go1.24 or any other earlier
version in which the feature is supported).
Of course, if no -lang option is used, or if the option is
-lang=go1.26 or later, the feature will not be available.
Since we do not expect wholesale removals of existing language
features, this should be a manageable burden.

I believe that this approach suffices for language removals.

Minimum language version

For better error messages it may be useful to permit the module file
to specify a minimum language version.
This is not required: if a module uses features introduced in
language version 1.N, then building it with 1.N-1 will fail at compile
time.
This may be confusing, but in practice it will likely be obvious what
the problem is.

That said, if modules can specify a minimum language version, the go
tool could produce an immediate, clear error message when building
with 1.N-1.

The minimum language version could potentially be set by the compiler
or some other tool.
When compiling each file, see which features it uses, and use that to
determine the minimum version.
It need not be precisely accurate.

This is just a suggestion, not a requirement.
It would likely provide a better user experience as the language
changes.

Language redefinitions

The Go language can also change in ways that are not additions or
removals, but are instead changes to the way a specific language
construct works.
For example, in Go 1.1 the size of the type int on 64-bit hosts
changed from 32 bits to 64 bits.
This change was relatively harmless, as the language does not specify
the exact size of int.
Potentially, though, some Go 1.0 programs continued to compile with Go
1.1 but stopped working.

A redefinition is a case where we have code that compiles successfully
with both versions 1.N and version 1.M, where M > N, and where the
meaning of the code is different in the two versions.
For example, issue 20733 proposes
that variables in a range loop should be redefined in each iteration.
Though in practice this change seems more likely to fix programs than
to break them, in principle this change might break working programs.

Note that a new keyword normally cannot cause a redefinition, though
we must be careful to ensure that that is true before introducing
one.
For example, if we introduce the keyword check as suggested in the
error handling draft
design
,
and we permit code like check(f()), that might seem to be a
redefinition if check is defined as a function in the same package.
But after the keyword is introduced, any attempt to define such a
function will fail.
So it is not possible for code using check, under whichever meaning,
to compile with both version 1.N and 1.M.
The new keyword can be handled as a removal (of the non-keyword use of
check) and an addition (of the keyword check).

In order for the Go ecosystem to survive a transition to Go 2, we must
minimize these sorts of redefinitions.
As discussed earlier, successful languages have generally had
essentially no redefinitions beyond a certain point.

The complexity of a redefinition is, of course, that we can no longer
rely on the compiler to detect the problem.
When looking at a redefined language construct, the compiler cannot
know which meaning is meant.
In the presence of redefined language constructs, we cannot determine
the maximum language version.
We don’t know if the construct is intended to be compiled with the old
meaning or the new.

The only possibility would be to let programmers set the language
version.
In this case it would be either a minimum or maximum language
version, as appropriate.
It would have to be set in such a way that it would not be
automatically updated by any tools.
Of course, setting such a version would be error prone.
Over time, a maximum language version would lead to surprising
results, as people tried to use new language features, and failed.

I think the only feasible safe approach is to not permit language
redefinitions.

We are stuck with our current semantics.
This doesn’t mean we can’t improve them.
For example, for issue 20733, the
range issue, we could change range loops so that taking the address of
a range parameter, or referring to it from a function literal, is
forbidden.
This would not be a redefinition; it would be a removal.
That approach might eliminate the bugs without the potential of
breaking code unexpectedly.

Build tags

Build tags are an existing mechanism that can be used by programs to
choose which files to compile based on the release.

Build tags name release versions, which look just like language
versions, but, speaking pedantically, are different.
In the discussion above we’ve talked about using Go release 1.N to
compile code with language version 1.N-1.
That is not possible using build tags.

Build tags can be used to set the maximum or a minimum release, or
both, that will be used to compile a specific file.
They can be a convenient way to take advantage of language changes
that are only available after a certain version; that is, they can be
used to set a minimum language version when compiling a file.

As discussed above, though, what is most useful for language changes
is the ability to set a maximum language version.
Build tags don’t provide that in a useful way.
If you use a build tag to set your current release version as your
maximum version, your package will not build with later releases.
Setting a maximum language version is only possible when it is set to
a version before the current release, and is coupled with an alternate
implementation that is used for the later versions.
That is, if you are building with 1.N, it’s not helpful to use a build
tag of !1.N+1.
You could use a build tag of !1.M where M < N, but in almost all
cases you will then need a separate file with a build tag of 1.M+1.

Build tags can be used to handle language redefinitions: if there is a
language redefinition at language version 1.N, programmers can write
one file with a build tag of !1.N using the old semantics and a
different file with a build tag of 1.N using the new semantics.
However, these duplicate implementations are a lot of work, it's hard
to know in general when it is required, and it would be easy to make a
mistake.
The availability of build tags is not enough to overcome the earlier
comments about not permitting any language redefinitions.

import "go2"

It would be possible to add a mechanism to Go similar to Python's
from __future__ import and Perl's use feature.
For example, we could use a special import path, such as import "go2/type-aliases".
This would put the required language features in the file that uses
them, rather than hidden away in the go.mod file.

This would provide a way to describe the set of language additions
required by the file.
It's more complicated, because instead of relying on a language
version, the language is broken up into separate features.
There is no obvious way to ever remove any of these special imports,
so they will tend to accumulate over time.
Python and Perl avoid the accumulation problem by intentionally making
a backward incompatible change.
After moving to Python 3 or Perl 6, the accumulated feature requests
can be discarded.
Since Go is trying to avoid a large backward incompatible change,
there would be no clear way to ever remove these imports.

This mechanism does not address language removals.
We could introduce a removal import, such as import "go2/no-int-to-string", but it's not obvious why anyone would ever
use it.
In practice, there would be no way to ever remove language features,
even ones that are confusing and error-prone.

This kind of approach doesn't seem suitable for Go.

Standard library changes

One of the benefits of a Go 2 transition is the chance to release some
of the standard library packages from the Go 1 compatibility
guarantee.
Another benefit is the chance to move many, perhaps most, of the
packages out of the six month release cycle.
If the modules experiment works out it may even be possible to start
doing this sooner rather than later, with some packages on a faster
cycle.

I propose that the six month release cycle continue, but that it be
treated as a compiler/runtime release cycle.
We want Go releases to be useful out of the box, so releases will
continue to include the current versions of roughly the same set of
packages that they contain today.
However, many of those packages will actually be run on their own
release cycles.
People using a given Go release will be able to explicitly choose to
use newer versions of the standard library packages.
In fact, in some cases they may be able to use older versions of the
standard library packages where that seems useful.

Different release cycles would require more resources on the part of
the package maintainers.
We can only do this if we have enough people to manage it and enough
testing resources to test it.

We could also continue using the six month release cycle for
everything, but make the separable packages available separately for
use with different, compatible, releases.

Core standard library

Still, some parts of the standard library must be treated as core
libraries.
These libraries are closely tied to the compiler and other tools, and
must strictly follow the release cycle.
Neither older nor newer versions of these libraries may be used.

Ideally, these libraries will remain on the current version 1.
If it seems necessary to change any of them to version 2, that will
have to be discussed on a case by case basis.
At this time I see no reason for it.

The tentative list of core libraries is:

  • os/signal
  • plugin
  • reflect
  • runtime
  • runtime/cgo
  • runtime/debug
  • runtime/msan
  • runtime/pprof
  • runtime/race
  • runtime/tsan
  • sync
  • sync/atomic
  • testing
  • time
  • unsafe

I am, perhaps optimistically, omitting the net, os, and syscall
packages from this list.
We'll see what we can manage.

Penumbra standard library

The penumbra standard library consists of those packages that are
included with a release but are maintained independently.
This will be most of the current standard library.
These packages will follow the same discipline as today, with the
option to move to a v2 where appropriate.
It will be possible to use go get to upgrade or, possibly, downgrade
these standard library packages.
In particular, fixes can be made as minor releases separately from the
six month core library release cycle.

The go tool will have to be able to distinguish between the core
library and the penumbra library.
I don't know precisely how this will work, but it seems feasible.

When moving a standard library package to v2, it will be essential to
plan for programs that use both v1 and v2 of the package.
Those programs will have to work as expected, or if that is impossible
will have to fail cleanly and quickly.
In some cases this will involve modifying the v1 version to use an
internal package that is also shared by the v2 package.

Standard library packages will have to compile with older versions of
the language, at least the two previous release cycles that we
currently support.

Removing packages from the standard library

The ability to support go get of standard library packages will
permit us to remove packages from the releases.
Those packages will continue to exist and be maintained, and people
will be able to retrieve them if they need them.
However, they will not be shipped by default with a Go release.

This will include packages like

  • index/suffixarray
  • log/syslog
  • net/http/cgi
  • net/http/fcgi

and perhaps other packages that do not seem to be widely useful.

We should in due course plan a deprecation policy for old packages, to
move these packages to a point where they are no longer maintained.
The deprecation policy will also apply to the v1 versions of packages
that move to v2.

Or this may prove to be too problematic, and we should never deprecate
any existing package, and never remove them from the standard
releases.

Hexbyte Hacker News Computers Go 2

If the above process works as planned, then in an important sense
there never will be a Go 2.
Or, to put it a different way, we will slowly transition to new
language and library features.
We could at any point during the transition decide that now we are
Go 2, which might be good marketing.
Or we could just skip it (there has never been a C 2.0, why have a Go
2.0?).

Popular languages like C, C++, and Java never have a version 2.
In effect, they are always at version 1.N, although they use different
names for that state.
I believe that we should emulate them.
In truth, a Go 2 in the full sense of the word, in the sense of an
incompatible new version of the language or core libraries, would not
be a good option for our users.
A real Go 2 would, perhaps unsurprisingly, be harmful.