Go Replaces Interface{} with 'Any'(github.com) |
Go Replaces Interface{} with 'Any'(github.com) |
type fileOps []any // []T where T is (string | int64)
Go does not have neither generics nor union types. So people have to do this kind of thing :( I feel sorry for them.Reminds of Java 4 (15 years ago or something) where code was full of this crap:
List /* <String> */ values;
Map /* <String, Object> */ map;
Some devs spent a whole week doing nothing other than removing those commented out generic type declarations once Java finally got generics!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.
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.
Fast-forwarding to today, could Co-pilot have saved them the trouble?
[0] https://www.jetbrains.com/help/idea/structural-search-and-re...
Oh good grief. There are 32,768 programming languages. People are free to pick the ones they want to use and nobody wants your pity.
for sum < 1000 { sum += sum }
while sum < 1000 {}
reads better (like English).Not a bad move, though. Most Go "Interface" things do tend to be "{}"
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.
I'd imagine you can continue using interface{} for the foreseeable future.
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.
Edit: I’m impressed generics aren’t a breaking change! I thought this changed Interface{} to Any as a breaking change
For the release announcement: https://news.ycombinator.com/item?id=29556646
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:
type any = interface{}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.]
https://medium.com/webstep/a-java-developers-adventures-thro...
Go often works well for replacing shell scripts, even without generics.
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".
Jet Brains makes GoLand, which is about as good as they come. It should feel familiar if you're used to IntelliJ. https://www.jetbrains.com/go/
[]interface{}{1, 2.0, "hi"} -> []any{1, 2.0, "hi"}
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!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.
func doSomething[X interface{}, Y Fooer[interface{}]](v X, src Y) error {
}
versus: func doSomething[X any, Y Fooer[any]](v X, src Y) error {
}
the signatures can get long pretty quickly when you have more than one type parameter. type any = interface{}
not work, if you felt that strongly?That would get rid of the "panic: runtime error: invalid memory address or nil pointer dereference" errors.
https://wakatime.com/blog/48-go-desperately-needs-nil-safe-t...
… 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.
> … 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.
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.
There is also a tutorial on generics: https://go.dev/doc/tutorial/generics
https://www.makeworld.space/2020/11/go_modules.html
Looking for a tutorial on generics myself.
The normie PL bar is rising, slowly but surely.
Is this a real command? If so, I’m very impressed. Is there any equivalent for c++ and other languages?
The combination of unambiguous syntax and consistently-formatted code results in rewrites producing meaningful diffs (most of the time).
[1]: https://pkg.go.dev/cmd/go#hdr-Update_packages_to_use_new_API...
[0] https://www.jetbrains.com/help/idea/structural-search-and-re...
func foo(x interface{String() string}) string {
return x.String()
}Same for an old Android phone with outdated Snapdragon 660 I use to test app performance bottlenecks. Except this one took 4 secs to load.
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?
People have written programs in both of those areas using Fortran-IV which doesn't have overloading either.
I suppose I've fed the troll.
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.
[1] Cornelius Diekmann, “Looping with Untyped Lambda Calculus in Python and Go,” Paged Out!, vol. 2, p. 22, Nov. 2019. https://pagedout.institute/ https://www.net.in.tum.de/fileadmin/bibtex/publications/pape...
If there is, I imagine it's not among the type of people who you'd want leading technical decisions at your company.
Also, application servers are cheap, persistent, fast and durable storage isn't, these won't be written in TS.
TL;DR: different problem domains, not important
You mean a reserved keyword? No, 'any' is not a keyword, it's an identifier (a predeclared identifier). The usual shadowing rules apply.
But having `any` as syntactic sugar for that concept is a good idea.
struct{} -> ?
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.
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.
Let's see how this plays out.
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.
Having lots of syntactic sugar works out OK for Haskell.
For example, in Haskell straight-line imperative looking code is an alias for some underlying callback hell.
The state of error handling in Go at the moment is embarrassing at best.
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.
(std::optional in C++ is a great example)
People makes it a big deal, in reality it's not.
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.
type Any interface{}
?
`type any interface{}` would declare a new type, while an alias is exactly another name for the same type.
type any = interface{}
That means they're interchangeable compared to defining a new type. type Any = interface{}
That is, it's an alias and casting is not necessary.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.
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.
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.
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.
“ZeroVer is satire, please do not use it”
Less has my favorite version numbering system. Sequential.
Latest stable release: Version 598
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?
(Also I see a 581.2 on that page.)
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!
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.
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.
I like Typescript a lot but the reality of Typescript development is not great.
Of course, Go makes zero attempt to enforce nullability (nilability?) in its type system... so... win some, lose some I guess.
Seems to me that the ones who write about "idiomatic go code" aren't the ones who are shipping softwares/libraries.
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.
(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.)
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.
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.
But it was just after the death of IE ;) [Call me lucky]
My 2 cents: widespread adoption is not synonym with long term perenity.
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:
<meta name="go-import" content="github.com/gorilla/mux git https://github.com/gorilla/mux.git">
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.
The protocol is actually super easy to implement. It's described here: https://go.dev/ref/mod#goproxy-protocol
That is complete nonsense.
The ability to create an open set of types is powerful, so is the ability to create a closed set of types, and they are not exclusive.
But go currently has only one of the two, which significantly limits its expressivity and type safety.
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.
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.
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.
may_fail_who_knows()
.map(use_value)
.map_err(some_error_processing)
.and_then(another_computation_which_can_fail)
.or_else(with_some_error_handling_that_can_rescue)
.unwrap_or(a_default_value)
Basically, instead of nested match expressions, you get a "pipeline". 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:
may_fail_who_knows()
.match {
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 {
Ok(y) => y,
_ => a_default_value,
}
Maybe not that different.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.
match may_fail_who_knows() {
Ok(success) => {
do_something_with_success(success)?
},
Err(failed) => {
some_error_processing(failed)
}
}
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. let result = may_fail_who_knows()?
.use_value_which_might_also_fail()?
.use_a_different_way_that_might_fail()?;C’est la vie.
>
> 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
callFooWithFunc (int lambda (argslist) { ... });
// Suffixed return-type looks normal
callFooWithFunc ((argslist) : int { ... });
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()`.
Upgrading is the problem in this scenario. Let's say 727 is the modern version. If users upgrade to 728, when 728 is based on 402, we have an issue.
No, we don’t.
Perfect for things like Ubuntu, pytz, and ca-certificates.
Less for something like a library who’s API could change and break your implementation.
It's useful for Ubuntu, because each Ubuntu release is a hodgepodge of lots of unrelated updates. Some will be breaking, some will be fine.
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).
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.
Edit: It’s also one of the most approachable languages.
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.
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 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.
It can be quite a mess for larger systems.
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 rest my case.
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") }
that does require a stack trace? 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?
Idiomatically, go uses errors for the purposes other languages use exceptions, so if this makes debugging harder, it's an important consideration.
I especially like f"{foo=}" for debugging.
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.
If you want an exhaustive type switch that badly, it’s usually a sign that your interface definition is wrong. https://github.com/nishanths/exhaustive
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 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.
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.
lol. That’s not how this works.
If I give my bugfix to 402 a version number, it will be 728.
So if that's not what happens, what does?
Do I give it no version number?
Am I not allowed to release a bugfix?
Surely you can see why both of those options cause unnecessary problems.
If you have something else in mind, you need to say it.
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.
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 should really be:
streamOf(choices)
.mapAsync(ce => getChoice(ce))
.throttle(5, 1.second)
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.
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.
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.
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.
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.
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.
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.
> 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).
Also, exceptions are just a kind of algebraic effects that do not resume. There was a proposal to JS for this: https://github.com/macabeus/js-proposal-algebraic-effects
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.
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.
- map / map_err
- and_then / or_else
- unwrap / unwrap_or
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
Zig manages to provide traces with very little overhead:
https://ziglang.org/documentation/master/#Error-Return-Trace...
I am baffled as to why error handling in Go remains so impoverished.
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.
However, doing this kind of ... sucks.
See eg 'maybe' (https://www.stackage.org/haddock/lts-18.18/base-4.14.3.0/Pre... on stackage) or foldr for lists.
'foldr' is interesting, because it encapsulates a recursive pattern matching on lists. For the non-recursive version, see 'uncons' composed with 'maybe'.
> I cannot wait for the Result monad.
Generics make this possible, and will be a huge improvement to Go's error handling.
I'm not saying it will solve everything, but it's a huge step nonetheless.
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.
[1] https://github.com/SeaQL/sea-orm/blob/64c54f8ad603df0c1d9da8...
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.
Unwrap would look like:
file := os.Open("foo").Unwrap()
I'll take that over the current go state of the art: file, err := os.Open("foo")
if err != nil {
panic(err)
}
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.Emphasis on eventually instead of at every single function call.
No. You can just feed it to the function (from a library / stdlib) that needs it, or call .fold() in the end.
that's similar to what Java does with the Optional type, not great, but not bad either
the alternative is checking for nulls which is worse in any possibile way
I usually implement something like Kotlin Result when I have to code in Java
with a couple of static helpers to build the result: Result.success(T) Result.failure(Throwable t)
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-result/
also Result in Rust has been an inspiration
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.
Elixir is my daily drive and working in Java made me miserable until I started working around the lack of pattern matching facilities
Error handling is the worst part, I think a simple
switch (retval) {
Ok(val):
...
break;
Error(err):
...
would make Java much more pleasant, even without full pattern matching everywhere.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
Either<User, IOException | BusinessException> createuser()
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.
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.
And it's not really a version numbering system that you're advocating for at that point, it's a strict rule about linear releases.
methods that declare checked exception can throw RuntimeException(s) so exhaustiveness cannot be totally enforced anyway
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()
}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 ?
It is very weird that this subthread starts with "current state of the art".
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.
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.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.
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.
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.
It's rather awkward to get this exactly right.
Convenience is not a goal of the language, generally.
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.
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".
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 didn't say it is. I said it should be. But here is a similar functionality that I just googled: https://www.learnrxjs.io/learn-rxjs/operators/filtering/thro...
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.
https://gist.github.com/jaydonnell/4fd14c69132734aac76b7f538...
What's the vanilla way in ts/js to do the equivalent of this golang code where getChoice makes an HTTP call and can't make more than 5 per second?
for _, v := range choices {
getChoice(v)
time.Sleep(200)
}
Also, why doesn't Deno.sleepSync work as I expect here?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?
https://scastie.scala-lang.org/AiusSr76QLCTrlZvBICT7A
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.
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.
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.
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.
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.
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.
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.
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.
17 -> 1.17, 11 -> 1.8, this is bothering me way to much for no good reason.
https://docs.oracle.com/en/java/javase/17/language/java-lang...
https://docs.oracle.com/en/java/javase/17/migrate/getting-st...
The x.y versioning with y being a synonym for major version was abandonend in Java 9.
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 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.
The PR version doesn't even have to be numeric. You can give them proper names.
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.
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.
If semantic versioning is used correctly, like here, that's actually a reasonable-ish attitude.
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.
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.
Semantic versioning is an approach to versioning. It's an approach which, as GP stated, was designed specifically to help with dependency updating.
GP isn't proposing that languages shouldn't be versioned, they're saying that semantic versioning is the wrong approach to versioning for a language.
This is actually very important. If something is a major change or not is pretty subjective.
I'm afraid that expectation isn't entirely warranted. Especially around standard library issues.
Why?
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.
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.
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)
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).
that's why semver works; the definition of major change is defined, and that's when you update the major version number.
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.
Also they may have sneaked it in because they're implicitly acknowledging fault in their previous design decision to exclude it.
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.
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.
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.
I actually wrote about that idea on my blog: https://blog.kronis.dev/articles/stable-software-release-sys...
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.
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.
Every additive language change would be a breaking change in this ReverseSemVer you’re imagining.
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
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.
* Java 8 is Java 1.8.0
* Java 11 is Java 11.0.11 (at the moment)
* Java 17 is Java 17.0.1 (at the moment)
SunOS/Solaris is what I use when I want to get nerd-rage mad about minutae: https://en.wikipedia.org/wiki/Oracle_Solaris#Version_history
It was confusing.
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'm just saying that they could have done something different, if they had wanted to, without working out a complete theory for that different thing.
Or alternatively, complain loudly.
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?
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.
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.
Or you are scared of GPL3.
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.
[0] https://devblogs.microsoft.com/cppblog/two-phase-name-lookup...
[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.
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. :)
https://docs.microsoft.com/en-us/dotnet/csharp/language-refe...
Yes, it would. Breaking is breaking.
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".
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.
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.
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)
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.
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.
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.
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).
To me, Python represents how not to do language versioning.