In semantic versioning, going from 1.X to 2.X is only indicated when there are incompatible API changes. Adding generics doesn’t break compatibility with preexisting Go code, so it’s unnecessary to increment the major version.
Sure, but semantic versioning really is the wrong kind of versioning to use for a language. The major version should represent major language changes, not whether its a breaking change or not, semantic versioning isn't somehow magically a "good" way to version. It's useful for libraries / dependencies where you are dealing with many different libraries and just want to know you can upgrade without having to deal with breaking changes. For a language? Silliness. Your version is not really telling you the main things you care about.
It's much much more useful to the users to say, 2.0 introduced generics, it's distinct. If it's like other languages, generics changes the code people generate a lot, libraries start looking significantly different. It's very distinct, and if that is simply in version 1.18.0 or whatever, that is super bad usability from a language perspective.
> Sure, but semantic versioning really is the wrong kind of versioning to use for a language.
A language or API (things you program against) are pretty much the things for which SemVer makes sense.
> The major version should represent major language changes, not whether its a breaking change or not
I don’t care if changes are “major”, I care if the code I wrote for version X is expected to need modification to work correctly in version Y. SemVer gives me that, Subjective Importance Versioning does not.
Can't agree more. One important information that the version number gives me is that if upgrading to newer versions will break my code. SemVer gives me that.
Arguably a language should NEVER have breaking changes large enough to warrant a SemVer major version update. Rename or fork the language if you want to do that. Such major language overhauls in the past have been a huge waste of developer time as they go back to rewrite affected code.
It's not the only two choices available. C# also introduced var back in version 3 (2008), but it did it as a "contextual keyword" - meaning that it remains a valid identifier in any position where it used to be one, even to this day. Pretty much all new C# keywords since the very first version are of this nature:
Whether something is breaking or not is not always clear-cut. For one thing, changes can become breaking in retrospect sometimes. For example, in C#, renaming a method argument wasn't breaking until the language introduced named arguments in calls.
There are also changes which are breaking in a very non-obvious way, to put it mildly. For example, in C# again, adding any new member to a class can break existing code that happens to pass implicitly typed lambdas to overloaded methods, because overload resolution involves checking whether the lambda body is valid for a given candidate - thus, adding a member can make a lambda ambiguous. I'm not aware of anyone actually treating this as a breaking change for semver purposes, though.
Nearly every big fix in an API is technically a breaking change if you want to be pedantic. This kind of collateral damage which requires multiple points of failure doesn’t usually count as a semantic major change.
> Nearly every big fix in an API is technically a breaking change if you want to be pedantic.
A fix is not a breaking change in the API, because “breaking” refers to expected behavior (so, yes, code that relies on a bug can be broken by a fix; presumably, if you've coded to an observed behavior differing from the spec you are aware of having done so.)
The solution clearly can't be to never ever fix bugs though.
But depending on the kind of bugs (especially when they are of the "gotcha"/"UX" kind) often it's better to just create a new API version with the corrected behaviour and keep existing software apply handle the old behaviour the best they could.
But clearly for many many other kinds of bugs (security etc) we are better served with a bug fix in the old API even if that implies a possibility for breaking somebody.
Does an example of semver used in a non-pedantic way even exist?
The perfect simple world that a naive interpretation of semver dreams of leads to those endless streams of major.0.0 increments fueled by better safe than sorry. "You have been warned, your code might break, no promises". After all there could always be some xkcd 1172 "every change breaks someone's workflow". In an infinite universe of monkeys on typewriters someone would have set up mission critical infrastructure based on an elaborate log4jndi deploy mechanism.
On the other extreme you have something like Java that's in the process of dropping a frozen first digit ever since 1.2. Sure, this predates semver by quite some time but if we'd try to designate meaningful major.minor.patch names in hindsight we'd certainly not go exclusively by the occasional new keywords in the syntax like var but by new feature groups like generics, lambdas and the like, most of which have been introduced without invalidating any old syntax.
"We're awesome at backwards compatibility, but this change is noteworthy enough to warrant a major, better introduce some artificial incompatibility" is something that should never happen.
It's clear the py2 to py3 migration was painful but I'm curious to hear how you would apply "they should fork / rename the language" here.
To me it just feels like semantics. They said "here's a new major version of python" when they could also have said "we have forked python 2 and we're calling it python 3. We think it's better and we will probably abandon python 2 at some point".
(FWIW, "semantics" would be "what it means", so I figure that's not what you wanted to say: from your example, I guess you are saying it's more that it's a different wording — syntax? — for the same meaning)
But it's about setting expectations.
The only problem I ever had with py2 to py3 migration was that it was even possible to have the same codebase run against both, when languages are incompatible to such a degree (most notably, basic type has changed). It basically forced people to make the worst use of the Python dynamic nature (as soon as the stdlib started doing that, there was no going back).
Semantics refers specifically to meaning of words/language. If you say "it's only semantics" then it probably means you both understand and agree on the concepts but not the meaning of the words surrounding those concepts. That applies in this case, with the concept being breaking changes to a language along the lines of Python 2->3, and the terms being "version change" and "new language".
Was? It still is painful as some companies have decided to keep and support python2 for another 5+ years. I have programs which require the same dep from different pythons.
To me, Python represents how not to do language versioning.
In Java people regularly refer to a particular JDK version as a Java 17 or Java 11, even though they actually refer to versions 1.17 and 1.11, respectively[0]. In Clojure land they just say 1.x, even when large new features are added.
I like this because it emphasizes the community's commitment backwards compatibility, which I greatly value. I've spent a good deal of time writing Javascript, where library developers seem to have very little respect for their users and constantly break backwards compatibility. In ecosystems like that, upgrading fills me with dread. When I see a library on version 4, I have learned to keep looking - if they weren't thoughtful enough about their API design for the first 3 major releases, I shouldn't expect it to be much better going forwards.
For an application, I'm pretty open to version numbers signifying big features - Firefox and Chrome do this, and it's helpful with marketing. But for a programming language? A programming language is a tool, and when upgrading you need to carefully read the changelog anyways. A programming language is no different from a library (in Clojure it literally is a library), and backwards compatibility is /literally/ the main thing I care about. Is my tool going to intrude on /my/ schedule, and force me to make changes /it/ wants instead of being able to spend my time making changes /I/ care about? I want to know that.
[0]This is apparently an awful example as I've just learned that Java is actually doing the major version only thing. It still sort of works because the only reason they can do that is because they Will Not Break Compatiblity.
> In Java people regularly refer to a particular JDK version as a Java 17 or Java 11, even though they actually refer to versions 1.17 and 1.8, respectively.
17 -> 1.17, 11 -> 1.8, this is bothering me way to much for no good reason.
I don't think 11 ever referred to 1.8 generally, but for the longest time the `openjdk-11-*` packages in one of the Ubuntu LTSes (18.04?) actually installed Java 8 for some reason.
> Sure, but semantic versioning really is the wrong kind of versioning to use for a language.
I don't agree. I usually don't care so much when a particular feature was introduced into a language (and if I do, it's usually a Wikipedia search away). I mostly care whether or not code written assuming version X can be compiled with version Y of the compiler. Semantic versioning can tell me the latter. Making versioning arbitrarily depend on what someone considers a "big" feature doesn't help me.
> I don't agree. I usually don't care so much when a particular feature was introduced into a language
I care very much when a feature was introduced into a language, because maintaining compatibility with earlier versions of the language determines what features may be used. If I'm working on a library that needs to be compatible with C++03, then that means avoiding smart pointers and rvalues. If I'm working on a library that needs to be compatible with C++11, then I need to write my own make_unique(). If I'm working on a library that needs to be compatible with C++14, then I need to avoid using structured bindings.
If a project allows breaking backwards compatibility, then SemVer is a great way to put that information front and center. If a project considers backwards compatibility to be a given, then there's no point in having a constant value hanging out in front of the version number.
> I mostly care whether or not code written assuming version X can be compiled with version Y of the compiler.
Semantic versioning can only tell that for the case where X < Y (old code on new compiler). In order to determine it for X > Y (new code on old compiler), you need to know when features were introduced.
> In order to determine it for X > Y (new code on old compiler), you need to know when features were introduced.
I think this is a deliberate reduction of dimensionality. Go says that you don't need to worry (for long) about this case, because the toolchain must be updated regularly - and promises that it will be as pain free as possible. This simplifies for the Go team, for library authors, and library users in most cases, at the expense of maintaining a recent toolchain.
Not saying this tradeoff is for everyone, and I've never used C++ professionally so I'm probably ignorant. But are you saying it's common with production projects that use a compiler from 2003 or earlier? What's the use case?
> But are you saying it's common with production projects that use a compiler from 2003 or earlier? What's the use case?
Modern C++ compilers are not necessarily available on all platforms. For example, Solaris, AIX or old RedHat versions. Go doesn't have this problem yet, but it will.
> Not saying this tradeoff is for everyone, and I've never used C++ professionally so I'm probably ignorant. But are you saying it's common with production projects that use a compiler from 2003 or earlier? What's the use case?
Let's start with the fact that newer doesn't mean better. With already deployed compiler you have tested it and know that it works good enough (code it generates, bugs you have workarounds for, etc). Where with new compiler you are on step one. You must do work again.
Or vendors just support particular version they have patched.
> Not saying this tradeoff is for everyone, and I've never used C++ professionally so I'm probably ignorant. But are you saying it's common with production projects that use a compiler from 2003 or earlier? What's the use case?
The first difference is that there isn't just a single compiler, but rather a standard that gets implemented by different compiler vendors. It's gotten better since then, but typically it would be a while between the updated standard being released and the standard being supported by most compilers. (And even then, some compilers might not support everything in the same way. For example, two-phase lookup was added in C++03, but MSVC didn't correctly handle it until 2017 [0].)
The second difference is that the C++ compiler may be tightly coupled to the operating system, and the glibc version used by the operating system. Go avoids this by statically compiling everything, but that comes with its own mess of security problems. (e.g. When HeartBleed came out, the only update needed was for libopenssl.so. If a similar issue occurred in statically compiled code, every single executable that used the library would need to be updated.) So in many cases, in order to support an OS, you need to support the OS-provided compiler version [1].
As an example, physics labs, because that's where I have some experience. Labs tend to be pretty conservative about OS upgrades, because nobody wants to hear that the expensive equipment can't be run because somebody changes the OS. So, "Scientific Linux" is frequently used, based on RHEL, and used up until the tail end of the life-cycle. RHEL6 was in production use until Dec. of 2020, and is still in extended support. It provides gcc 4.4, which was released in 2009. Now, gcc 4.4 did support some parts of early drafts of C++11 (optimistically known at the time as C++0x), but didn't have full support due to lack of a time machine.
So when I was writing a library for use in data analysis, I needed to know the language and stdlib feature support in a compiler released a decade earlier, and typically stay within the features of the standard from almost two decades earlier.
[1] You can have non-OS compilers, but then you may need to recompile all of your dependencies rather using the package manager's version, keep track of separate glibc versions using RPATH or LD_LIBRARY_PATH, and make sure to distribute those alongside your library. It's not hard for a single program, but it's a big step to ask users of a library to make.
Disagree with this. Most middle managers wouldn't understand the difference. One canonical version is enough, any more and there's just confusion, not enlightenment.
>It's useful for libraries / dependencies where you are dealing with many different libraries and just want to know you can upgrade without having to deal with breaking changes. For a language? Silliness.
A language update comes with the most fundamental set of libraries and APIs: the standard library (doubly so in Golang, which has a lot of batteries included).
It also potentially affects the behavior (if there are breaking changes) of all other third party libs.
The "silliness" part is a non sequitur from what proceeded it (and the following arguments don't justify it either).
>Your version is not really telling you the main things you care about.
The main thing (nay, only thing) I care about (for my existing code) from a language update is whether there were breaking changes.
I could not care less to have reflected in the version number whether a big non-breaking feature was introduced.
I can read about it and adopt it (or not) whether there's a accompanying big version number change or not.
>It's much much more useful to the users to say, 2.0 introduced generics, it's distinct.
That's quite irrelevant, isn't it?
It's not useful to users that follow the language (page, forums, blogs, etc.) and would already know which release introduced generics.
And it's also not useful to new users that get started with generics from day one of their Go use either.
So who would it be useful to?
Such a use would make the version number the equivalent of a "we got big new feature for you" blog post.
> The major version should represent major language changes, not whether its a breaking change or not
Why?
Old code still work and unless you are purposefully maintaining an old system you are expected to use the last version anyway. What does it actually change that generics were introduced in version 1.18 rather than 2.0? From now on, Go has generics. As there is no breaking change, it’s not like you had to keep using the previous version to opt out.
To play devil's advocate: many people are forced by circumstances beyond their control to use various old versions, or provide libraries and want to support people who are forced to use various old versions.
They're not using 2.0 because 2.0 might have breaking changes, and they don't want to burn the version number for something that doesn't break the Go 1.0 compatibility promise. Makes sense.
I thank Go team, that the major version was not increased. You can't imagine how much wider adoption of generics will be as compared if Go went to version 2.0 There are thousands of under-educated and overly-cautious software development managers who would prevent their teams from upgrading to a major version of Go until it is "proven".
When it comes to developers there are two types who read changelogs and would know their tool well and take advantage of every small change in each minor version and then there are those who are there for the money, they will find out about a new feature only if manager instructs them to use it.
> There are thousands of under-educated and overly-cautious software development managers who would prevent their teams from upgrading to a major version of Go until it is "proven".
If semantic versioning is used correctly, like here, that's actually a reasonable-ish attitude.
It makes no sense to replace a meaningful and helpful criterion - whether it breaks code or not - by some purely subjective assessment of what's a "major change." That just leads to usual version creep, from "Go 2.2" to "Go 3.0", to "Go 4.0", to (inevitable) "Go 10", "Go 11", "Go 11 Pro", "Go 11 Ultimate Edition",...
Wait, why wouldn’t it matter for a language? I want to know if I can compile my existing project with the new version… isn’t that an important thing to know?
Because in most languages, maintaining backwards compatibility is absolutely sacrosanct. It's not that you check the version number to know if your code will still compile. You check the name of the language to know if your code will still compile. (Yes, there are exceptions to this, but those tend to be cautionary tales. Python 3 is a better language than Python 2, but took a decade to gain adoption because it broke backwards compatibility.)
Since backwards compatibility is already a given for languages, you can then have the major version number indicate feature additions, rather than always being a constant value as semantic versioning would require.
I dont know if backwards compatibility is absolutely sacrosanct in most languages... for C/C++, sure... but I know Rust has broken backwards compatibility before, as has Python as you mention, and Ruby, too. I don't think it is as sacrosanct as you think it is, especially for relatively new languages under heavy development.
Sounds like you think this is some kind of "Web 2.0" situation, but that was just a marketing term.
Languages are software; they are dependencies of other software (the only unavoidable dependency!) and as such should absolutely be versioned.
Versioning isn't for marketing or providing easy ways for users to remember when features were released. It's a tool for change management. Exciting features often come with breaking changes, but not vice versa.
Not really from my perspective. I want know if there is any reason to not upgrade due to my existing code base breaking. Extra new features I could use are not such a reason. New reserved words are such a reason. If someone is using a new feature, I can just say "make sure you have the newest version, also here's my reason for keeping close to the edge of current versions, so that you amortize the labor of keeping up to date rather than pinning and then having some big migration project in 5 years."
It is pretty subjective, but not entirely so, and I would guess there's a pretty clear consensus around whether this is a big deal or not in the community?
I agree. It's a bit odd that I just learned generics were added. I expect minor bumps in language versions to be uneventful. Additionally, I expect a language to essentially never introduce breaking changes, so semantic versioning isn't really telling me anything.
Get your point, but I kind of like it. It tells some really important info, and they are hardly alone. Python 3.x was breaking change. Until they they stayed in 2.x versioning a long time. And we will never see a Python 4.x
In my opinion, significant additive changes are addressed more poorly than any other aspect of semver, regardless of the project scope.
Additive changes can be breaking changes quite easily, as those additions are adopted within a minor version range, as automated tooling needs to distinguish their presence, as documentation fragments.
My next biggest gripe with semver—that 0.y.z has entirely different semantics from any other major version—may actually be semantically better if adopted wholesale. If your interface changes, major version bump. Else you’re fixing bugs or otherwise striving to meet extant expectations.
> Sure, but semantic versioning really is the wrong kind of versioning to use for a language. The major version should represent major language changes, not whether its a breaking change or not
Major language changes almost implies breaking changes, like Python 2 to 3 was major changes that break things everything from how modules were changed, where they were, and some syntactic and fundamental changes as well.
I think we should go back to years (for most software, in fact, not just languages). Languages change slowly enough (or should) that, e.g. "Ada 2021" should be unambiguous enough. For language implementation versions, then we can add semantic numbers afterwards.
Ruby introduces new language features all the time in minor versions while maintaining backward compatibility. I can pretty much upgrade a minor Ruby version to get access to new language features without worrying about breaking anything.
I would definitely consider a language to be an API. Using semantic versioning makes perfect sense; because it communicates to consumers of the language which versions will require them to change their source code and which will not.
There are already effectively 2 ways to deal with language changes:
1. Min version in go.mod
2. Add a build tag for what to do for new/old version of go (These tags are automatic, you just need to set them in the files)
Semantic versioning solves one specific problem that's worth solving - whether you can (expect to) automatically upgrade. That is a problem people have with languages just as much as libraries, and it is a problem that affects both big changes and small just as much as it affects libraries. It is not the only way to solve this problem, but that problem very much needs to be addressed.
When a language adds any features, if your dependencies (whether real library dependencies or just things you're copying from Stack Overflow) start using the new features, you must upgrade to the new language version. That is an inherent usability constraint, and every time a language designer chooses to add a feature, they're making a tradeoff. But if upgrading to the new language version is trivial, then it's generally a worthwhile tradeoff.
For instance, suppose I find some code that uses Python's removeprefix() method on strings. I need to use Python 3.9 or newer to use that code. It doesn't matter that this is a very small feature.
However, I can generally expect to upgrade my Python 3.8 code to Python 3.9 without trouble. It's different from, say, code that uses Unicode strings. For that code, I need to upgrade from Python 2 to Python 3, which I can expect to cause me trouble. The version numbers communicate that. It's true that Python 3 was a "big" change - but "big" isn't really the point. The point is that I can't use Python 2 code directly with Python 3 code, but I can use Python 3.8 code directly with Python 3.9 code. There are plenty of "big" changes happening within the Python 3 series, such as async support, that were made available in a backwards-compatible manner.
As it happens, Python does not use semantic versioning. But they have a deprecation policy which requires issuing warnings for two minor releases: https://www.python.org/dev/peps/pep-0387/ It's technically possible, I think, that a change like Unicode strings could happen within the Python 3.x series, but that's okay, provided they follow the documented versioning policy. This policy addresses the same question that semantic versioning does, but it provides a different answer: you can always upgrade to one or two minor versions newer, but at that point you must stop and address deprecation warnings before upgrading further.
You are, of course, free to also have a marketing version of your project to communicate how big and exciting the changes are. Windows is a great example here: Windows 95 was 4.0 (communicating both backwards incompatibility with 3.1 and major changes) and Windows 7 was 6.1 (communicating backwards compatibility with Vista but still major changes).
Windows (and vs) version numbers are a pain in the ass though. Constantly need to check that the version you need to put in some config file actually corresponds to the version you really mean.
I haven't seen anyone do a good job of it, but it seems to me that if you wanted to try, the way to do it would be to make the marketing versions and compatibility versions so different that they can't be confused (like Windows 95 or better yet XP, not Windows 7) - and then make sure that your configuration files can accept marketing versions and silently transform them to compatibility versions.
I have sympathy for your sentiment, but you don't have to come up with a proper definition nor get everyone to agree on that definition.
The Go people can just make up reasonable version numbers without having an all encompassing theory with definitions, and they only have to convince themselves, not everyone on earth.
> The Go people can just make up reasonable version numbers without having an all encompassing theory with definitions
but "breaking change" IS the criteria for reasonable version numbers that they have chosen.
"breaking change" is easily tested and well defined.
"big change" is as far from well defined as you can get, because "big" is unquantifiable and subject to judgement and interpretation; i.e. a poor candidate for drawing boundaries.
I feel like this is perhaps a bit of a gap in semver tbh. Sometimes a purely additive change can be quite major, in the sense that it shifts the thing in such a fundamental way that you are unlikely to try to interoperate between before and after, and are likely to run into trouble if you do.
Basically, if 1.18 code is extremely unlikely to work against a 1.17 compiler, because a new (technically additive) feature is pervasively threaded through new code, I feel like it's hard to describe them as part of the same epoch.
I don't write enough go to know if that's true for generics, but it seems like it could become true fairly quickly from my experience with other languages.
This seems like it would apply to literally any new feature - new code won't compile on an old compiler. It isn't the purpose of semver though, semver is trying to help you upgrade old code safely.
I think the thing, to me, is that semver is very useful where these things are purely mechanical. This (at least usually) applies to libraries. Semver enables things like lockfiles as used by ruby bundler and later cargo and various other things. But not all systems are upgraded in mechanical ways, and not all 'breaks' are purely mechanical.
This is why you can't really use semver (usefully) for everything. What's a "breaking change" in a word processor?
Languages aren't used like word processors, but they also aren't exactly used like libraries either. People get stuck on language versions for different reasons than why they get stuck on library versions.
At any rate I think in practice I think languages that are trying to hew to semver concepts like this just wind up with a 'fake major version'. Since Rust, for eg., might never go to 2.x the 1. in front of 54 is really just academic. That's the "real" major version as far as anyone needs to know.
A lot of the arguments in this thread seem to be kinda tautological. There's no law that says they have to use semver, nor is there a law that says semver can't be imperfect. "Semver is semver because semver says so" is not a compelling argument.
I did not in any way shape or form "propose a scheme." I pointed out what I perceive to be a limitation in semver to describe some kinds of changes in some kinds of things in practical terms.
These replies are incredibly and bizarrely dogmatic. I never knew it'd be so hard to have a discussion about semver without people just going "semver is semver" as if that means something.
> These replies are incredibly and bizarrely dogmatic. I never knew it'd be so hard to have a discussion about semver without people just going "semver is semver" as if that means something.
You seem to have misunderstood. We're not pointing out that "semver is semver", we're pointing out that semver as defined with respect to compilers, which is to say, the ability of existing code to run with a new version of the compiler, and what you "perceive to be a limitation in semver" are fundamentally in tension.
The only non-perf changes that would be allowed as non-breaking changes if we addressed what you perceive to be limitations in semver are precisely those changes which would be breaking in semver-as-it-exists, and those permitted in semver-as-it-exists are precisely those which you perceive as limitations.
Happy to discuss semver, but fundamentally the thing here is that what would address your perceived limitations is... the literal opposite of semver. Which is fine! But something not being its literal exact opposite isn't exactly a flaw in the thing itself; you simply want something else entirely.
The dogmatism I'm perceiving here is at least in part that you're applying an extremely black and white approach to this. There are many possibilities between semver and "the opposite of semver", and there are options that exist along other axes from semver as well. The only way what you're saying here makes much sense to me is if you define "opposite of X" to be "anything that is not X", which is.. weird.
And again, I did not propose "the opposite of semver" or anything else. I said "some changes are poorly described by semver". It's a big leap from that to "I know exactly what you have in mind and it is every change is a major change." Which I... did not say anywhere?
Though, I do think that, as I mentioned elsewhere, this is approximately the net practical effect of using semver for things that have a principled opposition to breaking of backwards compatibility. If you never go to '2.0.0' then the '1.' part of '1.234234.0' is meaningless. Your version is just 234234.0 and you're playing the same game of pretend that '0.x' does in semver only with a bigger number. Again though, this is not me proposing that, it is me observing that the thing you're arguing against is possibly what's really happening anyways.
My observation is merely that every additive change breaks the ability of new code to work with old compilers, and that it would be really, really weird to have a system in which you bump a major version because old code continues to work, but bump a minor when it would break or something.
If you "aren't proposing" that, then all you seem to be saying is "I think sometimes major versions should signal that they break compatibility with old code and sometimes they should signal that they maintain compatibility with old code because of some never-promised loss of reverse compatibility with new code on old compilers" which ... sounds uninteresting, unreliable, and frankly terrible from the standpoint of anyone who wants automated tooling to be able to make decisions based on signals in the version numbers (which, while not 100% reliable, is still a huge win over the old days of "versions can just be whatever, man" imo)
doesn't matter how "big" of a change it is, it matters if it breaks anything. that's the whole point of semantic versioning.
also how is "big" even measured? meters? kilometers? it's immeasurable, which is why the rule is to update the version number based on what changes break existing code, because that can be measured
Go code that requires language (or stdlib) features first present in Go v1.n will not compile with any version of Go 1.m, m <= n. There have been a few of these (modules, soft-launched in (IIRC) 1.11, there were one or a few in 1.15, now generics in 1.18).
But, the backwards compatibility guarantee is that code that worked with Go v 1.n will work with Go v 1.j, for j >= n.
Next para is based on my recollection of the discussion around generics.
Specifically for generics, any code that doesn't use generics is untouched by the presence of generics elsewhere. Code that is, in and of itself, not generic will, in most cases, being able to call functions that are declared generic without extra hassle (there are most probably a few cases where a type annotation on the function using generics would be required). Code using generic data types probably needs to type-annotate, but there may be cases where it's not necessary.
Changing the programming approach seriously should be a major version change.
A language is an interface with humans. Switching to generics is a major change in the way to think about the source code. It's not an implementation detail which is not a big deal as long as the compiler can accept earlier source syntax.
Heh, i think that there are plenty of opinions about how software versioning should work.
Yours is a pretty good one, i think that software versions should be indicative of what it contains to the people using them, whereas some others primarily care about the compatibility with the other versions. Semantic versioning is better suited to the latter group, because it doesn't really care about what's in the software, beyond what the changes are when compared to the other versions - breaking functionality, non-breaking functionality or just fixes of some sort.
My own alternative would take a slightly different approach yet - a system that would indicate when something was released, as well as whether the release is supposed to be stable (think MySQL 5.7, but in a format like 2021-stable-1234), or something more like a rolling release/nightly build with the latest changes (in format like 2021-latest-2345), an idea that in part i shamelessly stole from the Unity game engine, Ubuntu and JetBrains IDEs, since having a glance at their versions makes it apparent what you're looking at.
Since then, i've started using that scheme for a few internal libraries in my dayjob to see whether it will work out (where switching to something else would be a matter of updating the CI, so less than an hour), as well as some personal projects.
Of course, each versioning scheme has advantages and disadvantages.
For projects like React Native, which claim production-ready and are presumably quite stable in practice, you’re back to version numbers being meaningless, except with the added bonus it’s not even useful for marketing..
Also for systems relying on continuous delivery in some form, like websites and web apps and SaaS. Because as a user you don't actually have a choice to use an update or stay on an old one.
Considering most places I've worked at just bump the minor number in perpetuity, a date-based version conveys a bit more info. Also so much easier to know when it's going out if you have a release cycle (e.g. you don't have to guess what date 3.12354.0 is going to prod, you would know it already from a version like 2021.12.25).
Semver and calver are sequential, so you can easily sort them and compare versions in terms of age. So you would know that jumping from 1.8 to 3.0 for example could be quite large.
You can't do the same with git commit hashes because you can't sort them based on the hash alone. I have no idea how to compare deadbeef and cafebabe without checking the code itself.
Especially, like, what if you release a bugfix for a significantly old version? Do you give it an up-to-date number? Do you not give it a number at all?
It's hard to say much when your replies are so short, but let me try to elaborate.
If a user that was happily using version 727 is now on a version that's almost identical to 402, they're now missing 300 versions' worth of code changes. You don't see how that's an issue? What if they were using functionality from that? Upgrading to 728 has removed all this code they were depending on!
If everyone follows your advice to upgrade, then everyone has this problem. Including you, unless you ignore your own advice.
I just tested on the dev branch and you can still use variables named `any`. The compiler is smart enough to tell variable names and types apart. You can even create a variable named any with type any with `var any any`
Yes, it's not restricted to generics. But the reason the alias was introduced is generics. Because they use interfaces to specify bounds (constraints) for type parameters (bounded polymorphism): [T fmt.Stringer], [T io.Reader], ...
So an unbounded type parameter is [T interface{}], or [T any] when using the shorter alias. It's a type alias / predeclared identifier defined in the universe scope:
I am not well-versed in Go, but isn't it because Go supports specifying multiple parameters with the same type by just using a comma. Your suggestion would lead to syntactic ambiguity except when only a single parameter is provided. Additionally one can provide only types when parameter values are not needed and this requires there to be a typename to differentiate between no parameters.
With generics, the metatype of a type variable is an interface type. If you just had T, it’s grammatically incomplete because you don’t specify the metatype. I guess you could just assume the any type if it’s incomplete, but that’s an unnecessary inconsistency in the grammar.
No, that would be a parsing ambiguity with arrays. And the consistency of type parameter lists with normal parameter lists where types are mandatory is an additional benefit.
It's not really extra typing. It's just a syntactic convenience considered beneficial by the core team. It doesn't actually break compiler parsing in any way, but it may lead to human confusion in cases where a variable or constant has been named "any". This is probably very rare, and the code will still compile.
Disclaimer: I am totally newbie in Go. But quite experienced in Java and Js/Ts.
I never jumped the Go bandwagon because of the lack of generics.
Can I now try Go?
The following article seems to say that the lack of other features can be frustrating (the lack of lambdas and the lack of the functional handling of collections seems problematic to me)
Also I wonder whether the language has a IDE support as good as IntelliJ with Java (safe function extraction, type-safe autocomplete)
Would the local gurus here please comment?
[the idea for me is to replace my tricky shell scripts with Go scripts.]
If you're main problem with a new language is that it's not written like the language you are used to, you're not going to be happy with the new language.
You can like generics without requiring everything to look like Java.
(In fact, generics have been a relatively late and reluctant addition to Java. They had been at home in ML-family languages and others for ages before.)
Hence I’m not happy with anything that doesn’t look like Java.
Go seems amazing otherwise, but I cannot get over the fact that it forces me to capitalize or not capitalize my variables, and has types in the wrong order.
Well then I guess it's Java for you until retirement. Go can feel a bit alien if you only know "C-style" languages, because its creators took inspiration from a wide array of languages, including Pascal. Actually Pascal-style declarations are easier to read, but you have to keep an open mind and not just go "doesn't look like what I'm used to, so it must be bad"...
C/Java style of annotating the return type of a function first, and then annotating the argument types before the name feels really old school at this point. Python, TypeScript, Go, Rust, etc. all opted for annotating after the name.
Since this is so prevalent in newer languages, despite a pretty strong tradition in the other direction, I wonder if there is a pretty good reason for this which language design experts are keenly aware of when they design new languages.
> C/Java style of annotating the return type of a function first, and then annotating the argument types before the name feels really old school at this point. Python, TypeScript, Go, Rust, etc. all opted for annotating after the name.
>
> Since this is so prevalent in newer languages, despite a pretty strong tradition in the other direction, I wonder if there is a pretty good reason for this which language design experts are keenly aware of when they design new languages.
My $0.02:
Maybe consistency between named functions and anonymous functions?
If you put the return type of a function before the name then it reads ambiguously when the name is left out (as in anonymous functions):
// Named function
int funcName (params) { ... }
// No-name function
int (params) { ... }
Which leads to the language needing alternative syntax or extra keywords when declaring anonymous functions:
// Something like this maybe?
int lambda (params) { ... }
If you put the return type after the function information but before the body then it's always consistent:
// Named function
funcName (params) : int { ... }
// No-name function
(params) : int { ... }
And, of course, to retain consistency you then make sure that all variables are declared the same way (type following variable name):
// Var declaration
myvar : int;
The disambiguation comes into its own when creating functions inline:
// Prefixed return-type looks odd
callFooWithFunc (int (argslist) { ... });
// Prefixed return-type requires extra keywords to not look odd
I can't stand the capitalization thing. I see this in C# methods and boolean values in Python every day and it sucks. I think Java got it right on that one.
Newer is not always better.
So I prefer to stay behind a little, if I can.
[I stayed away from JavaScript until ES6 and Typescript. And I think I avoided myself a lot of headaches being that cautious]
Go has function literals, which are basically lambdas but a bit more verbose. Typical Go style doesn’t use them as often as other languages. You might assign one to a variable, giving you an inner function. But loops are usually written as for loops, not map calls. One API call that comes to mind where you might use one is ast.Inspect. [1]
Go often works well for replacing shell scripts, even without generics.
> I am [...] quite experienced in Java and Js/Ts.
> I never jumped the Go bandwagon because of the lack of generics.
> Can I now try Go?
Probably no. You still would be very disappointed. Go's take at writing and maintaining software often is pretty repugnant to people with a strong Java or JavaScript mindset. If missing user defined parametric polymorphism ("generics") was a reason to not even _try_ it you will be offended by by other things Go does in its particular way. Be it error handling, pattern matching, concurrency, mutability, etc. Basically if you try to write Java (or JavaScript) programs in Go you will suffer and hate Go. Same for C++/Rust aficionados. Go's newly added "generics" still come without "library support".
I'm mostly a C# developer that uses a ton of generics and when I tried Go previously I was disappointed that it didn't have generics. But I continued on. And so I found a handful of other things that would irritate me or would be an inconvenience versus doing the same thing in C#. So generics alone is not the main problem here, it is going from a C#/Java mindset to a Go mindset. In some way we would probably get bored with Go because it is so simple/easy and not much to mess up, versus the super complex object empires in C#/Java land.
So the problem is not really a problem, go is just a different tool for a different kind of problem or if your brain works in a specific way - but I wouldn't call it a problem at all. In a way, go is what Buddhism is to other (more fully featured) religions. I think if you get proficient with you can probably have a very peaceful programming experience and not fight against the system (like in Java, half the battle is just battling the tooling).
Thanks for bringing this up, will give Go another look again, its been a while!
I personally don’t get bored when a tool is “too simple”, but when it forces me to do things by hand that the machine should be able to do. Like manually specializing a generic concept. I didn’t become a programmer out of love for repetitive tasks!
For some people building "complex" things is how they attach sense of accomplishment to programming. If it is not complex, it is just boring or just a toy, hence not worthy of their attention.
Golang is waaay simpler than other languages, as some were designed to be arcane from the start and other became that way over time. So Go's stewards have been doing a good job of enforcing their philosophy so that it doesn't become a bloated monster.
This feels like an unusual opinion. Lots of people from different backgrounds are happy with Go. And it's praised particularly for its concurrency model.
That said, generics are still new and won't have the strongest library support for a while, and it takes a bit for idioms to settle. It wouldn't be unreasonable to wait for ~a year.
Yes, but without generics it's hard to do much useful with lambdas in your statically typed language. You can't even write a filter or map function.
And eg Go's old workaround for polymorphic sorting functions was just atrocious: it was rather convoluted, and only really worked at all for in-place sorting.
"without generics it's hard to do much useful with lambdas in your statically typed language"
I challenge you to read the source for the Go standard library and maintain that position. Lambdas are orthogonal to generics in pretty much every aspect. You need to stop thinking in terms of "filter or map". Programming languages that didn't include these idioms existed long before Go, and will exist long after Go.
I welcome the introduction of generics, but I still don't have many places in many code-bases where they'll be used.
Map is a pretty fundamental thing you might want to do to your data structures (and more). Look up all the places that Haskell's "Functor" pops up for example.
Filter is much more limited in where it's applicable, that's true.
In any case, I already gave another example in my comment: sorting. And sorting's API is done terribly in the Go standard libraries.
The Go standard library does a lot with callbacks; and yes, it manages to get something useful out of them via something even uglier: mutation.
For IDE you can try goland(which is Intellij Idea for go).
For replacing shell scripts go should be fine. It's closer to C than Java, so you will often write "if err!=nil" which is not a problem actually. Go feels like C with Hashmaps and simplified threading.
Now that Go is going to have generics, all we need is sugar syntax for early return on error -- like more modern languages such as Rust and Zig have -- and Go may finally be pleasant to program in!
It's definitely more streamlined, but my concern would be that newcomers might not understand that it's just an alias. Learning Go really reframed my concept of what an interface is, and I thought that using an empty interface to represent "any type" was kind of ingenious, and helps reinforce its ethos.
An empty interface can represent any type because every type inherently implements an interface with no methods. And that's what Go is all about -- implicitly implementing interfaces.
If a newbie hops into Go and just starts using "any" I think they might assume it's a magic type that's at the base of everything, missing out on the fact that they're still taking advantage of interfaces.
Counterpoint: I'm an experienced Go dev but still think of "interface{}" as a semi-special "any" type, just because it fits my brain better conceptually. "Any type" is simpler than "an interface with an empty method set, which is of course matched by any type". I think this is a win.
Go is the most readable language I’ve used in terms of understanding other peoples complex code bases, and I’ve been doing this a long time and have used a lot of languages.
Edit: It’s also one of the most approachable languages.
Why don't you list the languages that you have used? Otherwise there isn't really any new information.
For example for, having done Java, Scala, Python, Groovy, Haskell, Typescript and a couple others, Go reads extremely horrible. It feels as bad as enterprisey Java to me.
It's far easier for you. It definitely isn't for me or in general. Thank you for answering the list of languages though. Makes me wonder what the concrete points are for, since I really have a different opinion. Maybe our minds just work very different; :)
From the list of languages you mentioned I understand that you probably enjoy ways to express algorithms in an elegant and (maybe) terse way. Maybe that's easier for you to understand. However all that abstraction is very far removed from what the compiler or the interpreter actually execute on the CPU.
For me an easy to understand program is one where the way memory is structured and the way execution modifies said memory is easy to follow. That's why Go, despite being very verbose in some cases, it's easier to reason about, than codebases doing similar things in higher level programming languages.
Just ran into a great example today. Was using typescript/javascript to hit an api that has a limit of 5 requests per second. One of these fails saying that it's making more than 5 requests per second, the other doesn't, is it obvious why? It took me several hours to figure it out, wouldn't have had this problem in go.
choices.forEach(async (ce) => {
let ce = choices[index]
Deno.sleepSync(220);
let cf = await getChoice(ce); // this makes the API call
Deno.sleepSync(220);
});
for (index = 0; index < choices.length; index++) {
Deno.sleepSync(220);
let cf = await getChoice(ce); // this makes the API call
Deno.sleepSync(220);
}
This is such a common use-case, it should really be in the streaming-library of choice. It's also a good example of how more abstract code is often better and has less edge-cases. In this example, of you have 4 choices, then these can send all at once without delay. This will be much faster compared to the code you posted, which will wait after each request, even though the rate-limit is not applied.
Apart from that, I don't think the second example is complete, where does ce come from here? And also, I don't know Deno, but calling "sleepSync" already looks like a bad idea to me, no matter where it's used - especially since calling a sync operation within an async doesn't make much sense.
I messed up my copy/paste, the 'let ce' line in the first example should be in the second example.
Regarding your example, where is this throttle method implemented in javascript? And, your code is not easier to reason about for me than the synchronous golang code. It's not clear how throttle is affecting the function call that happens before it. Compare to this.
for _, v := range choices {
getChoice(v)
time.Sleep(200)
}
I'm neither a javascript pro, nor a fan of this language. There are probably better solutions out there.
> And, your code is not easier to reason about for me than the synchronous golang code
I never said it is. If you would be familiar with only assembler, then assembly code would most easy to read. And you need to spend some time upfront to learn other languages/techniques that can make certain problems easier.
So here's the thing. Let's change the idea of this code a little bit and make it a challenge.
Let's say we want to improve the code:
1. Improve performance by sending requests as quickly as possible while respecting the rate limit. I.e. if we have 3 requests, send them all at once. If we have 7 requests, then they should all be sent after 1 second has finished.
2. If a request fails, we retry it up to 3 times and don't count it towards the api rate limit
3. If all retries for a request fail, we just skip it and continue with the rest.
I think this is a very practical real world example. I'm curious how elegant this can be solved in Go. I will also solve it and post my online-runnable solution afterwards. :-)
And then we reevaluate which solution is easier to read.
Here you go. Took me about 15 minutes to write, using < 50 lines of code, and not using any external libraries outside of the Go Project. The 15 minutes includes the time to create the server I used to test it.
Immediate question that I have (since I can't execute it easily):
What happens if we have 11 requests, each request takes 1 second to be responded to, but one of the first 5 requests fails 3 times and each time the reponse until failure takes 0.1 seconds.
Will all 10 successful requests be completed within 2 seconds then? Or will the failing request block the 11th requests for 0.3 seconds, meaning that all 10 successful requests will only be completed within 2.3 seconds?
I’m limiting it to 5 concurrent requests which is independent of the rate limit itself. The failing request will hold one of those 5 slots until all 3 failures are done, but won’t otherwise block anything.
13 lines and should behave like yours (i.e. is not optimal in the sense of fully utilizing the rate limit).
Curious to see your solution that fully utilizes the rate limit - it's not so trivial I think. :)
Anyways. I have to say that the Go-code is better than I expected (even for the suboptimal solution). But given my experience with higher abstractions (here streaming) I personally find the streaming solution much more readible.
Thanks, this was a fun experiment. I was curious to see what language you used. I've done a bit of scala many years ago, and I have to say that I HATED other peoples weird and often over complex scala code. I think this is the main difference in our point of view. I 100% agree that you can write better abstractions with a language like Scala, but you can also shoot yourself in the foot far far easier. If you have a small team of very talented devs, something like scala can be amazing. At a large company, with thousands of developers of varying levels, I find you get worse results with something like scala relative to Go.
I also think that a dev who just graduated college would be able to jump into my code and work on it fairly easily. I think they'd need a lot of extra training/learning to understand yours.
One question about yours, is it using a separate OS thread for each HTTP call, or some kind of async io?
> I also think that a dev who just graduated college would be able to jump into my code and work on it fairly easily. I think they'd need a lot of extra training/learning to understand yours.
this nails it. It's also what Go was designed for. People just shouldn't make the mistake to assume that this means would also be easier to understand a whole code base. It's not, it takes longer, since there will be so much more code. We pay the long upfront costs to learn our languages and tooling for the same reason we sent people to school for years: in the end it pays off.
> One question about yours, is it using a separate OS thread for each HTTP call, or some kind of async io?
Just like Go it uses "green threads" and will work and run requests in parallel even when just having one thread and/or cpu core.
Which means the solution isn't really optimal, since if every second request fails, the code won't take full advantage of the rate limit and only do 2.5 successful requests per second.
I think the requirement is a bit vague. I'm assuming an HTTP call, and that the rate limit exists on the server. Given this, there are two different error conditions: 1) the request makes it to the server, is counted against the server-side rate limit, but fails for some reason. 2) maybe the network is down, the server never sees the request, so the failure is not counted against the rate limit.
I opted to assume all failures are of the first type, which means my code should also behave as expected, but will be slower when encountering failures of the second time.
I'm actually curious, I think it would be easier to change my code to handle both types of errors, but I could be wrong since I don't know the scala stuff you are using that well.
Yeah, I guess I could have been a bit more clear. In the end it's fine, I just wanted to make it a bit more interesting and any additional constraint would have worked :)
The reason that the Scala code is so much shorter is because streams compose really well and a lot of common building blocks (such as the throttling) can be provided. This effect gets more important when the code base is growing.
Also, when things become more complicated, loops like the one you wrote tend to become very complicated. At least that has been my experience. It's fun to write it, but not so much to read or maintain it.
My experience has been the opposite. If I jump into a random Go code base, it’s been easier to figure out what’s going on than with languages that provide a lot more abstraction power. The reason, IMO, is that a bad abstraction is worse than less abstraction, and people are more likely to create bad abstractions than they are good ones.
I think it’s better with functional languages like scala and clojure, because bad class hierarchies with inheritance are the worst abstractions, but Go hits the right balance for me and I think for large organizations that have a lot of devs who move on and off of code bases on a regular basis.
I agree that you can get a better result with powerful languages, but that it’s unlikely in large orgs. I think software engineering at scale is more about sociology than people admit or realize.
I think I might agree when it comes to Java developers creating crazy class-hierarchies with no meaning. To me that is pretty much as bad as a desert of for-loops and low-level code, maybe even worse.
I'm praying for you that you at some point end up in a project with a bunch of good developers that work on a code base with a lot of good and high abstraction. The pleasure to improve your own skills in this area and feeling your productiviy rise is worth the pain of learning things to get there. It will be difficult to find this in bigger companies though.
> I think software engineering at scale is more about sociology than people admit or realize.
Yes, but it's only a matter of time. Software engineering is a growing and young field. There will be a time when we laugh about the bad code that was written, without standards and training. This time is not yet, but I'm looking forward to it. :)
Thanks for the great convo, really enjoyed it. I have been on a project with good devs, wrote a large scale web crawler in clojure for a famous research lab. These days I’m responsible for hiring/training/retaining hundreds of devs across many code bases, some dating back decades. It gives you a whole different perspective. Also, I don’t get to code all day anymore, but I still love to code, so squeeze it in on fun hobby projects. This also gives one a whole different perspective about what is important, and what is really worth keeping in one’s head.
I would probably require a book length post to truly answer this, but here's the best short version I can do. I think it comes down to a few things.
1. Some languages on your list are very flexible, and that allows them to be written in a very readable form with great discipline. People tend to not have great discipline thought, for various reasons: their bosses are pushing them to go faster and cut corners, they aren't experienced enough yet, etc. I'd say that python and typescript/javascript fall into this category.
2. I think inheritance is the devil, and a more functional style is better (by functional I mean first class functions, passing parameters, etc not purely typed). Java/Groovy fall into this category, they overuse inheritance and things like dependency injection with xml or annotations rather than far easier to read function calls with parameters.
3. Then there is haskell :) Haskell requires a phd in rocket science to be proficient, and most people have other things they want to do with their life. You also can't hire a team of 1000 rocket scientists so most companies can't go this route.
Edit: One other thing I'll add about typescript/javascipt because it's a bit of an oddball. There really isn't a typescript/javascript style because most devs end up writing javascript. The java people try to write java in it, the python people python, etc. This alone makes it hard to jump into a random JS/TS codebase and figure out what's going on.
Regarding Go, it takes away a lot of flexibility, which makes the code longer generally, but it makes it much more consistent across code bases. It's lack of inheritance prevents the devil from showing up, and it's largely a simple function call with parameter passing style all the way through the code base. Structural typing is also a godsend.
Edit 2: Also, package management in python is a complete train wreck.
Go is ok-ish, if you use small programs and solve exactly the same problems the language designers had in mind in exactly the same ways the language designers had in mind.
The constant repetition and near-but-not-quite copy-paste boilerplate everywhere makes it hard to spot the crux of what's actually going on.
I've used all of those and more and spent a decade deep in the purely functional Scala/Haskell camp. Diving into a moderate or large Go code base is always far easier for me even though I've used Go much less than the others. Before I even knew Go I would often look at various algorithms in Go just because it was so easy to understand exactly what was happening.
For algorithms, this might indeed be different. Not only are algorithms usually small and very focussed snippets, the emphasis is on performance and hence mutability, language primitives and shortcuts are common.
I can definitely see that languages like python or go beat pure functional languages in that regard.
For business logic and glue code (which in my field is the vast amount of code) I think it is the opposite though.
I agree with the parent though, Go code is super-readable and accessible. I've coded actual useful stuff in Java, C, C++, Pascal, Python, PHP, Pascal, Basic, (Jenkins) Groovy, Typescript, Javascript - and am probably forgetting some.
I also dabbled around in Rust and a bunch of other languages, but I wouldn't call that experience, although when it comes down to the initial accessibility and impressions, in this context, that is relevant I think. Go was hands down the most readable and accessible of the bunch, which probably has some roots in having a C/C++ background, and it being a pretty simple language.
Dev-env wise, it used to be a mess with the GOPATH etc, but that has mostly been resolved. And once past that, it was easily the language I picked up the quickest. It took me 2 or 3 days to actually write something useful. I've jumped head-first into codebases of large projects without even thinking about it, which I would have been very hesitant about if they had been written in another language. I've had to do that plenty for C++ and Java projects, but that always took some convincing of myself, and was never a pleasant experience.
The readability of Go is intrinsically linked to the error handling in Go. If the spec started accepting "clever" rules to do with auto-magic return / assignment of error values instead of explicit handling, then the cognitive load of code review increases. If every error path is explicit, then the review becomes simple and self-explanatory (at least when it comes to error handling).
I'm not sure error handling in Go is really explicit. Go checks that you assign an error and usually handle it, but it doesn't check that you handle all values of the error. If a function suddenly returns a new error value, the compiler won't help you here. This is like in language with unchecked exceptions, you have to read everything carefully.
By that logic, assembly would be the most simple and self-explanatory language. I'm not sure if these two attributes are sufficient to determine how readable/productive a language is in the large.
That combination of trade-offs makes it a great language to solve interview style problems in on a white board.
You can alleviate those larger scale problems in Python a bit with good IDE support, embracing type annotations, and going with a style that prefers immutability over action-at-a-distance.
I agree, and I think this mistake is at the root of most of what are (at least to my biased point of view) the big mistakes made by go.
Syntactical niceties and simplicity / obviousness are sometimes a tradeoff, but go 1.0 felt like the designers just folded their arms and refused to consider any niceness.
I still don't understand why anyone would use go when there are so many better choices, but at least they're fixing some stuff now :)
> I thought that using an empty interface to represent "any type" was kind of ingenious
It makes me a bit sad to read that. We all are on a journey to be become better developers every day. But things like that are like a distraction. They make you think you found a really cool and smart concept, but you actually didn't and it's essentially just a hack.
Not sure what the solution can be. But PL designers should be more clear about features that are just "hacks" and considered "bad" but necessary in practice due to certain constraints. Go's {} and nil-errorhandling are examples. Nulls (in any language) are another common example.
It's not a hack, it's the natural way of expressing it in the chosen type system. The use cases for interface{} are often hacks, because of a lack of parametric polymorphism, but that's different.
I kind of understand what you mean, as it's similar to what happened with prototypes and the late-comer class syntax in JavaScript, and in that case, it was a net positive change as most would probably say.
I'm an experienced programmer whos just learning go now and I never made the connection that interface{} meant 'an interface with no methods'. I've always thought 'interface{}' was the clearest wart on the language. Languages shouldn't be about teaching me things, they are just a tool. Keyboard input goes in, computer instructions come out. any is easier to read, more intuitive in its meaning and takes less keyboard input.
> Learning Go really reframed my concept of what an interface is, and I thought that using an empty interface to represent "any type" was kind of ingenious
There's a few other languages out there that work that way. The split is probably is probably 20/80. Though "popular" languages heavily learn towards Java-esque interfaces - with TypeScript and Go being the odd ones in that bunch.
Emulating sum types in languages without pattern matching is extremely awkward, to the point of being almost useless.
type Result[T] struct {
ok: *T
err: error
}
Great, so how do you work with it?
* You can have a `ok()` getter that returns `*T`. Now you you need an `if x != nil`
* `isOk()` + `isErr()`
* `unwrap() T`, which panics on errors
* `split() (*T, err)` that splits into separate values, especially awkward since you both need `if err != nil` AND dereference the pointer
That API is more awkward then the status quo, and doesn't buy you any correctness guarantees because eventually you have to do an `if x := res.ok(); x != nil` or `x, err := res.split(); if err != nil {` anyway.
Pretty much the only convenience you gain are functions like `map()` or `unwrap_or`, but eventually you always have to take out the value and without being able to pattern match you can't get an API that improves correctness much.
And countless of other functions making it very practical to chain computations without having to pattern match anything.
I do the same in Erlang/Elixir.
In Golang, I need to check every function call, and if I want to know where an error come from, I need to wrap it in an errors.New() because no exceptions = no stacktrace
Stack traces exist in go[1], I'm not sure why you consider exceptions to be the only way of getting a stack representation.
Not to mention that traditional exception handling advice I've been handed down from the gray beards is to always handle exceptions as early as possible, which is exactly what go forces you to do with their approach.
It sucks when you have to use it for everything, but it's a useful enough technique that most Haskell datatypes come with functions that provide exactly this conversion to church encoding.
'foldr' is interesting, because it encapsulates a recursive pattern matching on lists. For the non-recursive version, see 'uncons' composed with 'maybe'.
It's probably actually better than pattern matching when there are only two, as in those cases. But even at three, it becomes a mess. And more than three? Yuck.
If your language does currying similar to Haskell, and there's a good order to your constructors / arguments, even with three or more there might be some benefits with partial application.
I think you will find that Go's error handling will not change as much as you suggest. The majority of developers are extremely cautious of generics and found the status quo to be the best balance between readability and conciseness.
Convenience is not a goal of the language, generally.
> The majority of developers are extremely cautious of generics and found the status quo to be the best balance between readability and conciseness.
Most people who use Go have specifically chosen to use it though (or at least sought out opportunities, given that it's not something entrenched like Java being used due to inertia), so yes, most of them are fine with how Go is or they wouldn't be using it in the first place. If Go had generics from the start, I'd imagine most users would be fine with that too due to self-selection.
In Haskell, you can have functions that work generically over option-types, error-able types, lists, functions, tuples, etc.
In Rust, you have to specifically implement functionality for all of these.
But eg your 'map' function in Rust still works for all lists, no matter what item type. In Go before this change, you had to write a different map function for each item type that could be in your list.
In Haskell, the same 'map' function works for lists, functions, error-able types etc.
Huge no to anything that will require .unwrap() noise everywhere in the codebase. I use Erlang, my code does not have ".unwrap().unwrap()"[1] anywhere.
In Haskell, 'Maybe (Maybe Int)' is a different type from 'Maybe Int'.
That means that when you use eg a hash table that returns some kind null value like 'Nothing' on lookup when a key is not found, you can still stick exactly that kind of null value as a normal value into the table and everything will turn out fine.
Just like "panic(err)" is used infrequently, "unwrap" would be used infrequently. They're comparable, and for the cases where unwrap is okay (test code, once-off scripts, etc), I'd definitely prefer it to the panic boilerplate.
I don't understand this example at all. A Rust programmer would essentially never use unwrap on the return from File::open. It would be like checking open(2) with assert() in C.
Of course it is. Unwrap panics on error, while ? bubbles up the error instead. But ? wasn’t introduced to replace unwrap, unwrap was always frowned upon. It instead replaces match and return.
It was always frowned upon, but still ended up everywhere (including lots of documentation and example code) because it was less verbose than the match handling. The ? helped remove that last obstacle.
Thanks to the pipeline operator and pattern matching, it makes pretty easy to read pipelines. It does not completely replace the with statement (that was not the point) but it simplified a lot of code.
sealed interface Result<T>
permits Ok, Error {}
record Ok<T>(value: T) implements Result<T> {}
record Error<T>(error: Throwable) implements Result<T> {}
// to consume
switch (result) {
case Error e -> e.error().getMessage(),
case Ok v -> v.value().toString()
}
Nitpick, but I like to point out that Java’s exceptions are themselves a Result type/analog with them. You can unwrap it by a try catch, and can choose to rethrow it with minimal syntax. It even has a plus, it will auto-attach a stacktrace to the error case.
Not so much a nitpick because with checked exceptions your method signature can declare multiple types of exception whereas Result generalizes your specific exceptions to Throwable, which you can't pattern match with the compiler being able to enforce exhaustiveness.
So to achieve the same exhaustiveness as a method like
User createUser() throws IOException, BusinessException
you would need both a Result-like construct and union types
which few mainstream languages offer.
Otherwise you type is unable to express that only IOException and BusinessException are thrown and you don't need to handle a default Throwable case.
checked exceptions don't integrate very well with streams and reactive pipelines, to the point that basically all of then end up being wrapped and rethrown as unchecked
methods that declare checked exception can throw RuntimeException(s) so exhaustiveness cannot be totally enforced anyway
This is only the case for "checked" exceptions though; the important part about the Result type in Rust is that it won't implicitly propagate errors if you try to just use the success value but instead will give a compiler error. I don't code much Java, but my impression is that standard library methods throwing unchecked exceptions is far more common than panicking, the closest equivalent is in Rust.
A lot of people are going to write very un-idiomatic Go code with "Result" types built on simple generics that, 5 years from now, we'll all be kind of smirking at. Rust's Option and Result make sense because the language supports it (most importantly with match statements). They don't make sense here.
This seems pretty glib. There's pretty obviously a phenomenon where people try (or used to try) to write code for other languages (less commonly Rust, more commonly Ruby) in Go, and that code is kind of notoriously uncooperative with the rest of the ecosystem and hard to maintain. You'll find it in Go web stack libraries, in particular, or with code for people who tried to fake generics with codegen.
The majority of Go I've been hired to improve has suffered immensely from not using accepted idioms. Most of my work has been focused on introducing those idioms systemically.
Yea, I am not really fan of 1000 line stack trace for an error in single line. I like Go's error handling better. Also it is clear you like error handling in Rust/Java etc which is fine. Exaggerating over how bad error handling in Go is hardly productive when a) many people do like it b) people can switch language when error handling primary concern over everything else.
Well as I said people do like stack traces. Hell, I work on language which just has exception stack traces as error handling. I find it hit and miss not godsend when fixing production issues.
However, the topic is errors, not exceptions. Those are very different concepts. Of what use is stack trace information in debugging values that you have assigned error meaning to when not other types of values?
If you had a function
func add(a, b int) int { return a * b }
there would be no expectation of carrying a stack trace to debug it. So what's different about
func add(a, b int) error { return errors.New("cannot add") }
func read_file(filename string) (string, error) {
return "", errors.New("oops")
}
func foo() error {
a, err := read_file("a.txt")
if err != nil {
return errors.New(fmt.Sprintf("read a: %s", err))
}
b, err := read_file("b.txt")
if err != nil {
return errors.New(fmt.Sprintf("read b: %s", err))
}
// do stuff with a and b
return nil
}
func main() {
err := foo()
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
In a language with exceptions:
func read_file(filename string) string {
throw FileNotFound(filename)
}
func foo() {
a := read_file("a.txt")
b := read_file("b.txt")
// do stuff with a and b
}
func main() {
try {
foo()
}
catch (err FileNotFound) {
fmt.Fprintf(os.Stderr, "file not found: %s\n%s\n", err.filename, err.Stacktrace())
}
}
With the current go error handling, you need to add the informations yourself in the string, not as a real data structure.
And before you say "you can add the filename to the error message in read_file()", what if the function is defined in a dependency you have no control over?
An exception is a typed data structure that contains way more informations and value to automate rescuing.
Delegating error handling to a try/catch block with a typed data structure allows the caller to care for certain type of errors and delegate the others to its own caller. With the current error type in Go, what would you do? parse the error message?
This is definitely not the current state of the art. Errors are values with types just like anything else in the language; dynamically populating calls to errors.New() is super weird (the point of errors.New is mostly to create global errors you can compare against), your errors nest without using %w, and there are a bunch of simple libraries that wrap typed stack traces in errors, just like there are a bunch of simple libraries in Rust that exist to make error types compatible with each other.
Your "state of the art" Go code is not really a good example of how that functionality would be written.
If you want to check for a specific error condition, then just define a value for that error and use `errors.Is` to check for it. This works as you'd expect with wrapping: https://go.dev/play/p/rJIlKKSYn9Q
> With the current go error handling, you need to add the informations yourself in the string, not as a real data structure.
This is completely false! If you want to provide a structured error, then you just need to define a type for it. In your example, a Go programmer might use errors.Is(err, fs.ErrNotExist) and errors.As if they wanted to retrieve the specific file path that does not exist in a strongly-typed way, something like https://go.dev/play/p/hdHPLAVbQuW.
> Delegating error handling to a try/catch block with a typed data structure allows the caller to care for certain type of errors and delegate the others to its own caller. With the current error type in Go, what would you do? parse the error message?
Certainly not! I think there is a misconception that "an error is a string" -- in Go, an error is actually any type that satisfies the error interface, i.e. has an `Error() string` method. It can be any type at all, and have as many other methods as you like in order to provide the functionality you need.
> what if the function is defined in a dependency you have no control over?
There's nothing stopping you from writing `throw new Exception(String.format("file not found: %s", filename))` in languages with exceptions either. In both cases, it would be recognized as poor API design.
Regarding stack traces, Go makes a strong distinction between errors (generally a deviation from the happy path) and panics (a true programming error, e.g. nil pointer dereference, where the program must exit). Errors do not provide stack traces since there is no need for them in a flow control context, panics do provide stack traces for useful debugging information.
Your code is wrong. It would normally be written something like this:
func read_file(filename string) string {
panic(FileNotFound{filename})
}
func foo() {
a := read_file("a.txt")
b := read_file("b.txt")
// do stuff with a and b
}
func main() {
defer func() {
if err, ok := recover().(FileNotFound); ok {
fmt.Fprintf(os.Stderr, "file not found: %s\n%s\n", err.filename, err.Stacktrace())
}
}
foo()
}
However, exceptions are meant for exceptional circumstances (hence the name), not errors. A file error is not exceptional in the slightest. It is very much expected.
While you can overload exceptions to pass errors (or any other value), that does not mean you should. Your use of exceptions for flow control (i.e. goto) is considered harmful.
Ok I was wrongly assuming that panic was expecting an error type, in fact it's an interface{}.
> Your use of exceptions for flow control (i.e. goto) is considered harmful
Exceptions are a way to delegate error handling to the caller by giving them informations about the unexpected behavior. It implies that the expected behavior is the "happy path" (everything went well) and any deviations (errors) is unexpected.
This is far from a goto because you can have `try/finally` blocks without catch (or defer in golang).
This is also easier to test. assertRaises(ErrorType, func() { code... })
Almost every Go library I've seen just return an error (which is just a string), you'd need to parse it to assert that the correct error is returned in special conditions.
> It implies that the expected behavior is the "happy path" (everything went well) and any deviations (errors) is unexpected.
Errors are the "happy path", though. Your network connection was lost for the data you were trying to transmit so you saved it to your hard drive instead means that everything went well! Throwing your hands up in the air and crashing your program because you had to make a decision is not something you would normally want to do. If statements are present in most happy paths for good reason. That the inputs presented you with a choice does not remove you from the happy path.
Now, if you made a programming mistake and tried to access an array index that is out of bounds, then there isn't much else you can do but crash. Exceptions are appropriate for that kind of problem. They are exceptional in that they should never happen. Errors, on the other hand, are expected to happen and you can happily deal with them.
> Throwing your hands up in the air and crashing your program because you had to make a decision is not something you would normally want to do
And that's not something you do thanks to try/catch. You just handle the error where it's meaningful to handle it.
The happy path of "make a request" is that there is no network error.
The happy path of "make sure this request is sent" is that you handle the unexpected network error to save the request to disk for further retry.
If the disk is full, you're not on the happy path anymore.
In Erlang/Elixir, there is a philosophy of "let it crash" which is basically "delegate the error handling/recovery to where it's meaningful to do so". For example:
start a supervised process to send a request
if there is an unexpected network failure, let it crash
the supervisor retries on its own
Or:
start a process to send a request
if there is an unexpected network failure, let it crash
monitor the process to be notified when it crash
do something if it happens, like saving the request to disk
A "write_file" function can return many kind of errors:
- file not found (some folder in the path does not exist)
- permission denied
- disk full
When you call write_file, you might want to handle some of those errors, and delegate the handling of others to your caller.
You're still not addressing my main point. How do you check which errors you want to handle and which one you want to propagate with just a string describing your error ?
You don't. That's why Go errors are typed. You use `errors.Is` to check if any of the errors in a chain of wrapped errors is of a particular kind. It doesn't grovel through strings to answer that question.
It is very weird that this subthread starts with "current state of the art".
> And that's not something you do thanks to try/catch. You just handle the error where it's meaningful to handle it.
And it is always most meaningful to handle it immediately, so what's the point of introducing a application-wide goto jump, amid Dijkstra's warnings that doing so is harmful when you're just going to catch right away anyway?
> The happy path of "make a request" is that there is no network error.
If there is a network error, you're still happy. It is not like you screwed up as a programmer. What is there to be unhappy about? The network error input to your function is very much expected and part of the "happy path" as much as any other input to your application.
> if there is an unexpected network failure, let it crash
Network failures are never unexpected. It would be exceptional to never experience a network failure.
> How do you check which errors you want to handle and which one you want to propagate with just a string describing your error?
Why would your errors be strings? When errors are just plain old values like any other it is true that you could resort to using strings to represent errors, but it would be quite unidiomatic to do so. Kind of like how you can overload exceptions to handle errors, but just because you can does not mean you should.
That is also true of most languages. Java (and Javascript in its attempt to copy it) are about the only languages that actually promote using exceptions for errors, and in hindsight I think we can agree it was a poor design decision. That doesn't stop people from trying to overload exceptions in other languages, Go included, but in terms of what is idiomatic...
However, the question was asking what is different about errors compared to other values. The add function above contains an error, yet I don't know of any language in existence where you would expect a stack trace bundled alongside the result to help you debug it. Why would that need change just because you decided to return a struct instead of an int? And actually, many APIs in the wild do represent errors as integers.
> Java (and Javascript in its attempt to copy it) are about the only languages that actually promote using exceptions for errors
Python does. Ruby does. It's not just Java and JS. Go is very open about its approach being a departure.
> And actually, many APIs in the wild do represent errors as integers.
Many, many APIs in the wild are implemented in (or meant to be consumed from) C, which doesn't even have exceptions, so not using exceptions makes sense for them.
Very often idiomatic non-C host language wrappers for those APIs will fire exceptions when they get an error return.
And it's awful. I use EAFP locally (to avoid TOCTOU and the like) at low level interfaces but I don't let it bubble up out of a function scope, because it is a goto in all but name.
I've also been increasingly using the `result` library/data structure. It's incredibly liberating to return an error as an object you can compose into other functions, vs try/catch, which does not compose.
Yes I write python almost like rust, and it's great. Strong types, interfaces, immutable types. It looks nothing like "old school python" but also behaves nothing like it. Gone are the day of "oh it crashed again, fix one line and rerun".
Exceptions should be for exceptional circumstances, not errors.
Edit: I see this is controversial. What do you take objection to? Making your python look less dynamic and more like rust? Try it before you knock it. Python's my favorite language, but I do not agree that many of the common "pythonic" patterns are good at scale.
Maybe I'm just too inexperienced, but I don't see the point of this library.
The linked example shows a really basic sqlalchemy model lookup. What does spewing these new types all over my code get me that returning None or an empty dict/list doesn't without the overhead?
def find_user(user_id: int) -> Optional[User]:
user = User.objects.filter(id=user_id)
if user.exists():
return user[0]
else:
return None
Not only is this idiomatic, it conveys the same semantic meaning. I'm using an IDE, as is anyone else working in a large codebase. I'll be told at the point of invocation that find_user could return None and I need to possibly deal with that.
> Not only is this idiomatic, it conveys the same semantic meaning
No, it doesn’t. For a single computation like this, that pattern is roughly equivalent to Maybe (which contains no information about the case where there is no success result besides that it is absent) rather than Result (which has error information, kind of like an exception, but in the normal return path.)
For a series of computations, the composability of both Maybe and Result means that they are semantically richer.
Couldn't I just return a tuple instead if I wanted to give the caller information on the nature of the success or failure?
Also, both Django and SQLAlchemy throw proper exceptions on bad queries or DB errors, which is probably the right thing to do in the average app using these libraries (the exception bubbles up, getting logged, returning the appropriate http error, etc).
I'm not crapping on this library, mind, I just can't find the use case that justifies it.
How do you compose a function that returns an int or an error with a function that takes an int as parameter?
Yes you can split this in multiple steps, or you can use monads to handle the composition for you, making you type less code, giving more information to the typesystem (mypy for python for example) about what is valid and what's not.
This is a completely different programming style, it's functional programming, aka: "how to make functions by composing other functions".
I find, that the problem with exceptions in Python is not so much that it is a goto, but that they are a bit like dynamic scoping (vs static scoping of variables), so you can not reason about them statically.
An example: suppose you have a function that does some work with the filesystem, and also calls some user-supplied code. (Perhaps because the user can subclass something etc, or because you are getting a callback, the details don't matter.)
Naturally your function might have some idea how to handle its own filesystem trouble, but you have no clue how to handle any filesystem exceptions that come from user provided code.
Definitely not. Especially because early Ruby implementations brought huge overhead when exceptions were used, you were strongly advised to only use exceptions for actual exceptions. Ruby was one of the first languages that really started pushing the idea that exceptions should be reserved for exceptions, even if was just for technical reasons.
Those overhead problems have been addressed and are no longer a problem, but the sentiment has continued to ring true. I agree that doesn't stop people from trying to overload them, as I said earlier. But idiomatic? Not at all.
Ruby has two kinds of exception handling. There is try/catch, this is used for flow control and should not be used for error handling (it doesn't have stack traces). And then there is raise/rescue.
Using try/catch as intended is a bit of an art, but raise/rescue is everywhere. It is absolutely the primary way of handling errors. I think you might be confusing these two.
Stack trace was part of a proposal to add to the error package but it didn't happen. You can use third party errors packages like https://github.com/pkg/errors which wrap errors with stack traces.
People downvote it, but it's true. 2/3 of your Go code is `if err` blocks. And the way you have to chain the error messages to make the stack make any kind of sense is just maddening.
You must handle an exception in a language like Java. Yes, you can just print the stacktrace, but that is a choice. I don't HAVE to handle errors in Go until the program crashes as well. I never really understood that argument.
I’ve been doing a lot of rust programming recently, but how exactly is the Result monad better? I feel like I end up with nested match statements for chained results, but maybe I’m doing something wrong.
let x = match may_fail_who_knows() {
Ok(y) => Ok(another_computation_which_can_fail(use_value(x))),
Err(e) => with_some_error_handling_that_can_rescue(
some_error_processing(e)),
};
match x {
Ok(y) => y,
_ => a_default_value,
}
It's a bit more verbose than using the combinators, but someone coming across it for the first time will understand it immediately because there's less to remember to understand it (this is where go really shines).
Also: by avoiding functors there are fewer subtle lifetime issues and `move ||` stuff to deal with and you can return from the containing function and use the `?` operator.
During the discussions of how `.await` was going to work for rust async there was the proposal to add other suffix keywords. So this would look like:
You just had to write the concrete types (Ok and Err) out. What if these types are changed later on, e.g. to "Some(...)" and "None" or "Ok" and "ManyErrs(...)"?
As you said, it is easier to understand. Because it less abstract. This can be a good thing, but as well be a bad thing - but one thing is sure: while it does the same in the concrete case, the code is not "equivalent" when it comes to refactoring and certain changes.
> the code is not "equivalent" when it comes to refactoring and certain changes.
Yes, there are a very specific and limited set of changes you could make to the types here and not have to change this code. You can't replace `Result<>` with `Option<>` because of `map_err`. You could replace `Result<>` with something else that is very `Result<>`y, but your flexibility would be very limited if you didn't want to change the signatures of `with_some_error_handling_that_can_rescue` or `some_error_processing`.
I'm sure it's possible to contrive an example where this would help, but I don't believe that it would be that much of a help very often in practice. I think it's just a bit more monady and people who take the time to learn monads then want to apply that wherever they can.
I'm not saying that the combinators should never be used, but that each additional one you use increases the cognitive burden of reading your code. So the question becomes: which of the combinators are worth it.
I would argue that `.map_err()` is useful as it compliments the `?` operator. Hopefully with (and often without) `try!` blocks many of the other ones can go away. In particular I think that language constructs are almost always better than `.and_then()`.
As `do_something_with_success` in a closure can't early return from the function (since it's in a closure), which makes sense, but just annoying to read nested results.
That does clean stuff up a bit, but still requires nesting if you need to access more than one value generated in the chain at a time. Go's pattern of val, err := ... is a little cleaner in that regard, but does have a lot of redundant if err != nil checks.
Can you kindly post a snippet of what you want the early return on error syntax sugar to look like? I'm trying to map it in my mind on how I'd do it in C# and how it would look in Go.
Yes, please, the iferr pattern drives me nuts. While I built a few years of my career on golang, I'm absolutely sick of the language at this point. At least we finally get generics.
There is a reason very robust software is written in C: no exceptions means you think about normal errors all the time and reason about what the difference bwtween an error openjng the file and an wrror writing the file. Sqlite, linux, cpython, all bullet proof and all C. The lack of exceptions more than makes up for memory unsafety issues.
There's many options, just look at other modern languages. The simplest are exceptions/errors that can be thrown - like you get in Java, python, typescript, etc. For something more like a traditional return value, you have std::result in rust, and error union types in zig, with language support to guarantee you check and handle the result. In C, you have macros and goto that can be used to reduce boilerplate and implement whatever pattern you want.
I would also like discriminated unions, and a more sane approach to code generation than go generate and writing your own binary that parses source and spits out source code.
Basically macros, like in Rust, not like in C. Rust can do stuff like the serde serialization library while Go has to rely on reflection with its obvious performance drawbacks.
Go could do something similar if you're willing to run `go generate` as part of your build process. For most Go applications, the reflection overhead is a fine price to pay for convenience, just like GC is a fine price to pay for not having to deal with the borrow checker. Obviously, these tradeoffs don't hold for all programs, but Go has definitely found a niche.
I'm not sure what you're arguing with. I know these things. I'm still missing the ability to have efficient serialization (as one example) easily. It matters.
I’m not arguing. I was just highlighting that you could easily implement a json library that uses go generate instead of reflection. I was positing that such a library hadn’t been made yet because the perf hit of reflection is fine for most people. Also, just an aside, if perf is a concern than JSON isn’t a good choice to begin with.
> all we need is sugar syntax for early return on error -- like more modern languages such as Rust and Zig have -- and Go may finally be pleasant to program in!
Can you provide an example? What's wrong with `return err`? That's a very explicit early return on error, no magic or language features (= added complexity) needed.
I'm not sure I like this change. I liked interface{} since it just works out naturally from Go's relatively simple type system, and anyone could come to the conclusion without actually being told "use interface{} to represent any possible value" just by having an understanding of that type system. Adding what is simply a type alias, multiple words for the same underlying concept, to me just feels like jargon. I admire Go for its simplicity and clarity and this change gives me anxiety that that will be obfuscated.
I don’t think the concern is unreasonable at all but ‘any’ feels pretty solid as far as I’ve had the chance to poke around. There’s some tensions where I think ‘any’ is arguably much simpler:
The baseline is that constraints aren’t best expressed as interface literals in situ. Unlike exceptional use of ‘interface{}’, ‘any’ will be a more naturally invoked constraint.
Also, some of the uses of constraints rely on a unification involving an ‘any’ term that cancels out. Here, the use of ‘interface{}’ is not incorrect but maybe indirect.
with generics you wind up with two parameter lists, and type parameters have metatypes. All type parameters need to have a metatype, and the metatype `any` comes up a lot. Once you start using generics you'll start to feel the pain of writing `interface{}` in those signatures, especially when you are dealing with functions like this one:
I agree with you right now, but I wonder if I will change my mind after having had some experience using the new generics. Maybe it will feel more natural then?
Good, now we just need the ability to declare function parameters and return values non-nullable (Forbid passing nil into a function, and declare a function will never return nil).
That would get rid of the "panic: runtime error: invalid memory address or nil pointer dereference" errors.
… and pattern matching. Maybe just some extensions for `switch`.
… and one of the `try` proposals.
That having been said… I do appreciate that Go has gotten where it is today by being radically simple, and that a lot of extreme care needs to be done to add new features to the language. It’s hard to draw a firm line in the sand. I feel like all of these features would work great together, though; it’d enable Go to do something like `Result` in Rust with few language-level changes.
I even remain somewhat skeptical about generics, but I am hopeful.
I agree. I don't understand why the designers wouldn't want the language to be expressive. Wouldn't it be better to have an expressive language with conventions than one that's as rigid as it is today?
In C++ there could be 7 or 8 different ways go about implementing similar functionality. This is powerful in that it lets you do exactly what you want because each of those ways is subtly different and sometimes you need each one.
Go's proponents think it's okay to be a bit less powerful so that there is only 1 maybe 2 ways to do something. This makes code at different companies more similar.
It also mean that a new grad can join the team, read the code, copy it, modify it some, and it's pretty much right. A staff SWE and a new grad will right very similar code in Go. In C++ it's anyone's guess how similar their code will be.
Ruby is all about conventions, most Ruby, and by extension Ruby on Rails apps look pretty similar despite the fact that the language is extremely expressive.
I don't buy that a staff SWE and a new grad will write very similar code in Go.
As far as simple language. It is possible to design simple languages that are very expressive, Lisp and Erlang come to mind.
> … and pattern matching. Maybe just some extensions for `switch`.
Go has type switches which are… ok. If it were possible to “close up” interfaces and type switches took that in account (match completeness) you’d be done about done, you would not have the structural / patterned unpacking but that’s probably less of a concern.
the fact that the set of types that can satisfy an interface is open is the source of their power. "closed interfaces" are broadly an antipattern in Go.
you can make an exported interface with an unexported method in it and voila, nobody else can implement it. That’s just broadly not that useful so it doesn’t come up a lot.
there are tools to do this, they’re not widely used because it doesn’t fit Go well. You’re trying too hard to write Go in the style of another language.
here, have fun. You’re gonna write some tests, make new types to satisfy interfaces for testing, and then wind up with branches for your test paths in your live code, but go for it, I guess. You know everything! I am but a simple blubbite, too dim, too dim to get it.
Yes, the hilarious arrogance of… taking in account useful things that work?
> there are tools to do this, they’re not widely used because it doesn’t fit Go well. You’re trying too hard to write Go in the style of another language.
Literally the exact same statement could be made about generics.
> If you want an exhaustive type switch that badly, it’s usually a sign that your interface definition is wrong.
Wonderfully missing the point again.
> You’re gonna write some tests, make new types to satisfy interfaces for testing, and then wind up with branches for your test paths in your live code, but go for it, I guess.
Oh boy, ain’t that one for Principal.
> You know everything!
You’re the person declaring sum types useless in the face of decades of evidence and completely incapable of handling the idea of disagreement.
> I am but a simple blubbite, too dim, too dim to get it.
I see you don’t understand the blub paradox either.
I don’t enjoy writing Scala even if the pattern matching syntax is basically the same as Rust. Some of it is the build tool, some of it is the JVM, and some of it is simply distaste for the language.
I don’t hate Scala and I’ve even written a few PRs in Scala, but I have had trouble picking it up for actual projects.
Go is extremely pragmatic and often favors clarity in how code will execute and simplicity in syntax and grammar. It’s basically a GC’d successor to C in many regards. It eschews classes for interface-based polymorphism, it compiles and runs code very fast, and above all, I’ve found it easy and rewarding to pick up.
I don’t want all of the expressiveness of Scala. Just a bit more than Go has today. Not much more.
I think a language that doesn't focus much on backwards compatibility isn't a great comparison to Go :) Having written some Scala, I really like the language. I also like Go's excellent performance, easy concurrency, ability to produce a single binary without having to rely on JVM etc. I think it would be a bad idea to get overly complicated constructs into Go but union types/pattern matching are relatively accessible, not uncommon now and provide a lot of expressiveness without the risk of code becoming unreadable. Those combined with maybe some alternative approaches for error handling will make me want to write Go again (I've written one component in Go that is working perfectly but it was just too much code with bulk of it being err != nil).
> Good, now we just need the ability to declare function parameters and return values non-nullable
But that's already possible, just declare arguments and return values as values instead of references. I mean you'll get the zero value if you don't use those functions properly, but those won't cause nil pointer dereference errors.
I mean, it's a tradeoff between performance and developer competency. To be harsh, I think nil pointer dereferences are developer error, not a flaw in the language.
So now that go has generics and modules, is there an up-to-date intro for writing cutting-edge Go code for people who are already pretty familiar with the older styles?
I tried doing the modules tutorial a while back but had difficulty with the directory structure. At some point I think the "recommended" directory structure became mandatory and I don't think my personal dev environment ever quite lined-up with it. Perhaps I should wait another year then buy a book.
That’s “a particular directory structure” that conflicts with many already-existing projects. What if I have a mostly-java project that has a couple bits of go code buried in it that I want to use as modules? Break it up into multiple repos for no good reason? No single tool should be acting like it owns the root directory of my repo.
I hate this pattern too but there's not "no good reason", there's a specific reason why it's like this, and it's a conscious design choice.
When you call `go get somedomain.com/whatever/thing`, what it does is makes an HTTP request to that URL, then looks for a `<meta>` tag on the page telling it where to download the module. Check out the source of any Go project on github and you'll find the relevant `<meta name=go-import ...>` tag. Here's the meta tag for the gorilla router, a common http routing library:
The tool is designed so that you can serve modules directly from version control repositories, because the goals were to avoid having any central authority on modules. It turns out this is a huge win in terms of making it dramatically easy to empower people to release modules, AND a huge win for decentralization: if you can host a git repository, the git repository is the module source, and that's the entire system. Just use Go as normal and make a public github repo and it's automatically importable through `go get`. No central registry required.
It's a tradeoff. In making it "one module per repo", it dramatically lowered the barrier to entry for producing modules that other people could adopt right away, but it requires that you have one module per repo, because the versioning scheme is based on tags having semver version strings in them.
Could they have made it something like "a tag can specify a directory and a version"? Hahha, yeahhhhhhhhhhh probably. But they didn't.
In any case, you can also serve modules out of a go module proxy directly, but then you have to set that up yourself. That would free you of the "one module per repo" thing, but now you don't get "a repo is automatically accessible as a Go module". You'd have to implement the Go module proxy protocol yourself. I'm ... doing this now in my spare time but it's not really ready to share yet.
As far as I know this was always possible since modules were introduced. You just have to prefix the version tags for Go with the subdirectory in the repo: "my/sub/directory/vX.Y.Z"
The only difference is that you are expected to tag differently. For instance if you had reporoot/hello/go.mod, you would create tags like hello/v1.2.3
While it's good to get up-to-date, I don't think anyone should go re-write code or radically change their programming style to use generics. There's no need to over-complicate things. Anywhere that you're either copy-pasting a bunch, or using code generation might be a good fit for generics.
I was in an interview not long after format strings in Python came out (for example, print(f”Hello, {name}”) rather than print(“Hello, %s”, name)) and the interviewer thought I was confusing languages and features, asserted this fact strongly, and was pretty surprised when it worked, but I think overall I lost points because the interviewer lost face.
about 10 years ago I had a discussion with an interviewer (he was actually consultant manager) who said "c# and Java are exactly the same in terms of runtime architecture" which I very much disagreed with when speaking strictly, which wasn't appreciated.
Why does 'interface{}' feel unlike 'interface{String() string}' to you? Just because it doesn't have a method? You know that you can inline non-empty interfaces, too?
I think people agree that those look similar however the problem is that the first is a common practice while the latter is something that no one in their right mind would do. If people replace the second with something like Stringer, then the first should be replaced too.
Limiting a phone's usable life to about five years feels somewhat arbitrary, and there are iPhones older than the (original) SE that are still getting security updates (5S/6).
Going to be downvoted for this, but it would be cool if Go had operator overloading. That way, you could write matrix code in it. I think if Go did this, the language could end up being used in computer graphics and Deep Learning.
Oh, and maybe this goes against the Go ethos, but Python has a library called Sympy for Computer Algebra, and it's quite nice. It clearly relies on operator overloading.
I'm guessing this won't happen for a long time because operator overloading has a reputation (rightly or wrongly) for being abused. Oh well!
[edit]
More philosophical point: Overloading in programming started with mathematical operators, no? The motivation for having it as a language feature is very clear (if you know enough mathematics). What happened?
I think your comment is designed to troll me. The fact that it's so short and arrogant makes this more likely to be true. Just to be clear: Deep Learning means ConvNets or something fancier, which only became a hot research topic circa 2010 (though they also existed as a prototype in the late '80s). I doubt anybody has programmed ConvNets in Fortran IV; a language from 1962. And even if they did program a prototypical form of ConvNets (I suppose because they were originally an obscure proposal from the '80s) in Fortran-IV, it wouldn't be able to use such essentials as the GPU, or convenience features like autodiff, so in today's world it would be a toy.
I hear what you're saying, but why make one language look like another when that other language is more suitable for those purposes (graphics and deep learning)? Isn't Python the go-to language for deep learning? It's probably better to stick to that one, because the field itself is hard enough already - learning another language on top will just put people off.
I appreciate the broad philosophy of Go, which is that it's a simple language. Much simpler than Python which (and this is subjective) has made some questionable design choices. I also like how it's not exotic or surprising; you can just get straight to hacking.
I also like that Go is fast. It appears that some Python code is being rewritten in Go to make it go faster. Python's slowness is a problem in both the areas I've mentioned, and I know the usual workarounds.
> Much simpler than Python which (and this is subjective) has made some questionable design choices.
One of the reasons golang is so simple is that they move very carefully on issues like this. It has its benefits and its drawbacks.
Adding operator overloading kind of opens a can of worms here. If they add that, why not arithmetic types? Why not exceptions? Why not x, y, z... Everyone wants just _one_ extra feature that would improve the language. Trouble is everyone disagrees on what that one feature should be.
Emailed the authors. No plans right now but Donavan talked about the big year of changes ahead, so I'm guessing sometime after the dust has settled from changes in 2022.
I just want to write f(g(), h()) without nine lines of useless noise that editors don’t know how to hide. I guess it might be possible to write a generic function that uses panic/defer to return an error from a subexpression.
same :) maybe also relax rules a bit to allow type elision for structs in function calls to get python like keyword args. foo("blah", {option1: true}) would work better than functional options people use now.
In Raku/Perl6 these things can be renamed and shaped on the fly. I think that is an amazing capability of a language. Its a little bit mind boggling to me.
Typescript is also far slower both in terms of runtime and compilation time, uses much more memory, and is far more complicated which in turn has led to a ton more compiler bugs. It also inherits a ton of questionable features and API decisions from Javascript and has a tiny standard library which means most Typescript developers repeatedly end up turning to the NPM ecosystem which is an unmitigated disaster. Deployment also isn't even remotely as simple and reliable as Go even with Deno compile.
I like Typescript a lot but the reality of Typescript development is not great.
This isn't super clearcut. Typescript has lots of footguns and famously says that soundness is not one of their driving factors. There are lots of ways to devolve to accidental implicit any's (even with the no implicit any strict rule turned on).
Of course, Go makes zero attempt to enforce nullability (nilability?) in its type system... so... win some, lose some I guess.
The usual end result of Go is machine code, machine code is not safe to the degree you would consider Go to be safe. Yet you consider Go to be safe but not TS.
I don't see how they're competing, TypeScript can still share code between client and serverside, go "can't".
They're not exclusive to each other, routing "form submissions" though a ts verification layer and then down to Go makes sense to me if you need the Go performance but want to share things client and server side.
Also, application servers are cheap, persistent, fast and durable storage isn't, these won't be written in TS.
This is great, as much as interface{} is not great, it's still part of the language and really verbose/non-descriptive. I'm guessing any was a forbidden word?
It wasn't so much that `any` was forbidden as that `interface{}` practically meaning "any value" fell naturally out of Go's existing type system rules, so there was no need to introduce another concept.
But having `any` as syntactic sugar for that concept is a good idea.
You can define that yourself as "type token = struct{}" or similar. In my experience struct{} occurs even less than interface{}, and it's also 3 characters shorter already, so it's not worth a builtin. I use "chan struct{}" for synchronizing and sometimes use "map[string]struct{}" for sets.
We use the alias "unit", like lalaithion has said before, in many projects. It's a bit annoying to declare it in every package though. Hopefully, there'll be an official alias in the future.
Go has had generic List<> and Map<> since before Go 1.0, though.
It's really interesting you used those as your examples because that need was fully met in a type-safe way already, and other examples are actually much rarer to come by.
It's even more interesting you chose to actually comment on the particular example I gave rather than general generics for user-defined types, which is clearly what I was talking about.
Trying to counter argue against an example by using the actual example, rather than the point the example is trying to convey, is a common, but quite dishonest debate strategy.
The behavior of builtin maps can’t be customized at all. Now at least it’s possible to define a sorted or threadsafe or immutable map with a typesafe API, though it takes replacing all reads and writes with method calls.
Not being able to customize built-in containers is a huge win in terms of readability though. Principle of least surprise and all.
If I see a map or slice I know exactly how it behaves, how much memory it consumes, what the runtime behavior is. If you make those generics instead then I have literally no fucking clue what's going on.
Some languages even have real for statements because it makes more clear what the intention is.
In Pascal, you can’t break out of a for statement, the limit and step value are evaluated only at start of the loop, and you can’t change the value of the variable being looped over.
Consequently, the number of iterations through the loop taken is known when the loop gets entered.
It does, but with generics coming down the pipe, I feel that "any" expresses intent somewhat better in certain circumstances. Or at least it ought to improve readability-at-a-glance.
I'd imagine you can continue using interface{} for the foreseeable future.
It does and it's enough for "while". What I do miss sometimes is the repeat-until / do-while loop. You can emulate it with "for" currently, but it's awkward.