Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

> Due to our size, we don’t need any kind of backwards compatibility, we just update everything.

Then just use 0.y.z versions and be done with it?

If the library constantly changes and everybody expects that, then that seems fitting.

I like Go's major version handling very much. If it's backwards incompatible, it's basically a new library under the same name and development team.

In my opinion making major version updates so painful also incentivizes not making backwards incompatible changes in libraries, which results in the Go ecosystem being very stable overall (something I value a lot in my day-to-day).



>If it's backwards incompatible, it's basically a new library under the same name and development team.

This, this, and a hundred more times this.

Incompatible is incompatible. There is no "kinda incompatible", "99% compatible" - when it comes to dependencies, they either work properly or they don't.

Software should be eternal. Without being strict about semantic versioning, it is impossible to make it so.


There absolutely is fuzziness in incompatibility.

I can change one part of an API and leave another untouched - that's part compatible, part incompatible. It's only an issue if you used the changed part.

(If you think that first one always counts... what if the changed part is literally called YouMustNotUseThisFunctionOrYourCodeWillAlwaysBreak() ? It's clearly implied to not be part of your intended API, despite technically being part of it.)

I can add something to a type, in a way that's backwards compatible at compile time... but common reflection patterns might cause everyone's code to explode.

I can make a change that solves a bug that someone was accidentally relying on by doing the wrong thing, but doesn't affect compile-time behavior, nor runtime for anyone using the library the way they should. But that bug-user's code is now broken, is this an incompatible change?


> I can change one part of an API and leave another untouched - that's part compatible, part incompatible. It's only an issue if you used the changed part.

When you commit to a version 1, you assume that every user of the package is using every feature you provide through your official API. If you break any slightest piece of the API, you've broken compatibility. It might "kinda work" for many users, but it will almost surely cause significant pain for many others.

> I can make a change that solves a bug that someone was accidentally relying on by doing the wrong thing, but doesn't affect compile-time behavior, nor runtime for anyone using the library the way they should. But that bug-user's code is now broken, is this an incompatible change?

It is not an incompatible change, and it is the responsibility of the bug-user's code to fix the bug in his program.

Of course, when such a thing happens on a large-enough scale, the API developer sometimes cannot afford to "fix" the behavior and force countless users to fix their programs, so the quirk just becomes de-facto part of the API.


Even as someone who has a lot of sympathy for strong backwards-compatibility guarantees, the position you have put forth here is ridiculous to the point of absurdity and is a level of backwards-compatibility that I assure you you have never once experienced from any library (including SQLite, which is generally considered to be extremely pedantic about compatibility and yet hasn't even attempted to live up to your impossible standard).


It's not necessarily a commitment to an impossible level of backwards compatibility, but rather a commitment to communicate any failure to attain that level clearly by bumping your major version number. I've found that to be pretty doable. (Which does usually come with a commitment to at least strive to attain that level, and to batch changes that do not live up to it.)


That's a good point.

Semver doesn't force you to do anything. You can stay on v0 forever for all it cares, or you can bump a major version every day.

What semver doesn't allow you to do is lie to your users.


> When you commit to a version 1, you assume that every user of the package is using every feature you provide through your official API. If you break any slightest piece of the API, you've broken compatibility.

That is not how semver works. If you read the documentation [0], it says right there that the public API must be clearly defined but that can also happen through documentation and you only have to up the major version if you break that specified public API.

You can certainly consider it bad programming practice to expose something via the means of the language that is not defined as public API but it is not against semver to do so.

[0]: https://semver.org/


If you think that way, then everything is incompatible, because the person using your lib could always do a hash of your lib file and create code that depends on it:

    if hash(libpath) != predefinedHash {
        log.Fatal("broken compatibility teehee")
    }
In the context of semver, "API" means "public API". There is no other API but public API.


Yes perhaps I misunderstood you. I was trying to point out that semver is okay with having elements in the "public" API of a library (public in the sense of you can import and use it, i.e. not hidden in private modules etc) but explicitly documenting that it is not considered public API in regards to semver.

If that is what you meant with "official API" then we are on the same page.


> I can make a change that solves a bug that someone was accidentally relying on by doing the wrong thing ...

There is even a "law" for that: https://www.hyrumslaw.com/


Not when it comes to Go's compiler, which is a binary acceptance function.


>There absolutely is fuzziness in incompatibility.

And that's not something to accept and work around, but a problem, one that Go's hard stance solves.


How do you define "compatible"? Semver doesn't define it.

An HTTP server can remain API compatible, and still drop or break support for a major feature you care about. Surely you don't want that to ship in a patch release.

Adding a new struct field, even a private one, can break API compatibility in Go if people use your struct without named fields. Do you want a new library in this case or not?

Semver doesn't cover CLI compatibility, either, do you want a CLI redesign to remain v1 or become v2?

Nuance matters. Stating to "just be strict about semantic versioning" doesn't help, semver is fuzzy.


This is a good point, arguably even fixing any bug breaks compatibility, at least if you're a kernel maintainer. An (in)famous Linus quote:

    If a change results in user programs breaking, it's a bug in the
    kernel. We never EVER blame the user programs.


I don't think this is all that useful of a quote outside of operating systems (and even there I still find it of questionable value). You really need to define how people can use your software (at least at a high level) and receive backwards compatibility guarantees.

Even in the Linux example, kernel modules do not receive compatibility guarantees because it is difficult to keep. You may need to rewrite your module depending on how it is written when upgrading the kernel. It also doesn't apply in case of security vulnerabilities and certain classes of bugs. Technically viruses can be user programs which rely on those vulnerabilities and even excluding those, there are cases where some API seems benign but later turns out to be flawed (like precise timers in the context of browsers).


I think it's a difference in what one considers to be "the API". Linux is very much to the de facto API side, whereas some other project might be more one the de jure API side with a rigid specification and allowing any change within that. Most things are probably somewhere between the two.


And yet even the kernel deprecates and removes things like legacy architectures, filesystems, etc.


I'd define it as 'everything you've documented so far still works as documented, and possibly if you're aware that many of your downstreams are relying on some undocumented behaviour, you try to account for that too (but that part is a nicety, not a commitment).


For some examples of how complicated determining what is "breaking" can be in general, here's a StackOverflow wiki that I created a long time ago to document this kind of stuff for .NET:

https://stackoverflow.com/questions/1456785/a-definitive-gui...


> Nuance matters. Stating to "just be strict about semantic versioning" doesn't help, semver is fuzzy.

That's why I prefer ComVer: https://gitlab.com/staltz/comver


ComVer doesn't account for changes that don't change the API.

For example, assume that you're using a dependency, libparser, v1.0, which uses ComVer. In version v1.1 they add a new function, foo() which does something that you want. However, at the time of you writing the software, the latest version was v1.2, which you use because you're a good engineer who likes to keep things up-to-date.

Now think about what happens when you distribute your application to three people:

- person 1 has libparser v1.0 - you program can't work, because v1.0 doesn't have foo(), so you obviously must require minor version that your program was built with - which is v1.2

- person 2 has libparser v1.2 or higher - you program works.

- person 3 has libparser v1.1 - you program CAN work, since v1.1 contains foo(), but it WILL NOT work, because your application requires at least v1.2.

Now, you might say, "well a developer should know that foo() was added in v1.1 and pin that as the lowest required vesion" - in which case I reply that ComVer doesn't help you a tiniest bit with this, because both API extensions and bugfixes are mashed together into a minor version, so now you have an order of magnitude more changelogs to go through.

SemVer stricly separates API extensions and bugfixes so you can just browse the minor version changelogs and see which version added the feature you require.


> How do you define "compatible"?

I define it as "provides the equivalent API".


I’ve added a method to a class. You use the same method signature but with different meaning in your code base. My release breaks your duck typing code. Was my release compatible? Is that an equivalent API?


If a class with its methods is a part of your API, and you add another method to the class that doesn't change the behavior of other methods, that it's a backwards-compatible release.

Other than that, I don't understand your question. What does "use the same method signature but with different meaning" mean? What is my "duck typing code"? Is duck typing part of your API?


In golang, you can reasonably have a collection of things accessed through interfaces, check whether each one conforms to additional interfaces, and call the methods of those additional interfaces if so. If user code does this, then adding a new method to a library can cause user code to break.

You may say "Well, don't do that," but it seems like golang users think of this as a way to provide backwards-compatible API upgrades[0].

[0]: https://avtok.com/2014/11/05/interface-upgrades.html


Switching to Python examples from Go, but I believe it all applies just the same.

> What does "use the same method signature but with different meaning" mean?

    .draw(times: int)
In your code that adds a shape to the canvas. In my code it adds a card to your hand.

> What is my "duck typing code"?

You collect everything with the draw signature as options to draw with.

> Is duck typing part of your API?

What do you mean “your API?”. Maybe I mention it in the docs, maybe I don’t. But that doesn’t really matter because if we’re going to compare intent, then this is subjective. And it really doesn’t matter because duck typing isn’t part of an API, it’s a technique / language feature. By just adding a method, I removed your ability to do something.

My point with all this is your definition is lacking. You just shifted the pain of defining “breaking change” to the pain of defining “compatible API”. Which is fine if you’re then going to define that strictly (see Hickey’s talk on SemVer), but you haven’t.


An API is defined through two parts:

1) Machine-readable definitions of the functions and data types used

2) Human-readable explanation of what the previously defined functions do when passed each of the allowed data types, with a high-level explanation of how is the API supposed to be used.

The first part is usually implemented as part of your programming language - in case of Python, type hints can be used as a substitution.

The second part is necessarily informal, since it targets humans. But that does not mean it cannot be well-defined. For example:

    mmap() creates a new mapping in the virtual address space of the calling process.  The starting address for the new mapping is specified in addr.  The length argument specifies the length of the mapping (which must be greater than 0).
There is no concept of "virtual address space" or "memory mapping" in C. All C knows about is functions and values. But we still know what mmap() does and there is absolutely no ambiguity to its behavior.


> Other than that, I don't understand your question.

It's simple: a user inherits from your class and adds a method to their user-defined subclass. You then ship a new version of your library that adds a method with the same name to your class, but with different semantics, and other parts of your library call this method expecting it to have the semantics you defined. You've just broken the user's code, even though you say this was a "backwards-compatible" release, and it isn't the user's fault because they were using your library as it was documented.


> There is no "kinda incompatible", "99% compatible" - when it comes to dependencies, they either work properly or they don't.

> Without being strict about semantic versioning, it is impossible to make it so.

There is no semantic versioning if you're that strict. You have to update the major version on every single release, no matter the contents, if your idea of backwards compatibility is that absolute.

Let me give you a concrete example: I have a go package with this code:

    var ErrBadThing = errors.New("somthing bad happened")
    func DoThing() error { /* maybe return ErrBadThing */ }
I notice I had a typo in my error message, "somthing" when I wanted "something". I fix that typo. Is this a breaking change?

The answer is "it depends"

    err := mypkg.DoThing()
    if errors.Is(err, mypkg.ErrBadThing) { /* will still work after change */ }
    if strings.Contains(err.Error(), "somthing") { /* will no longer work after change */ }
So, is it a breaking change or not? I would argue "no, not breaking, if the user uses the API as expected, it does not break", but you may argue otherwise.

If you argue that is a breaking change, well, what about adding a new method, which breaks reflection (like 'reflect.NumMethod()' returns one more, so if someone relied on indexing into your methods with reflection, you broke em!)? What about someone downstream applying a "patch" to your code before compiling it? Any change can break that.

The go authors have taken a much less strict approach. Go is still semantically "v1", but they've made a ton of breaking changes, from making `net/http` silently switch to http/2, to changing the semantics of various tls and security related functions, etc etc.


I would argue that you cannot do semantic versioning in a useful way without an API specification of some sort. For most projects, that would be the API documentation. If the only specification is the implementation, then every behaviour of the library is in the spec and every change is a breaking change.


Yes.

This is an inevitable consequence of Hyrum's Law[0]. It's been observed before but I find Hyrum's Law a very good and concise summary of the problem.

[0] https://www.hyrumslaw.com/


It's a good summary of the problems that exists in any sort of engineering, ultimately, they are actually people's problems


If your API guarantees that the error you return has a specific type, then you have to follow your promise.

If your API only specifies that AN ERROR is returned, then it's a bug in the user code to rely on an implementation detail.


No, it really shouldn't be. It makes it impossible to distinguish between those "theoretically this a breaking change" kind of changes and the "you're going to need to rewrite a bunch of code" kind of changes. At the very least libraries should be allowed to define how the library must be used in order for their semantic versioning to apply. For languages which allow "import * from x" style imports, the library should still be allowed to add in new functions without that being a breaking change.

If you want your software to be pristine forever, you really need to pin your dependencies, ideally via copying the source of the library into your repo so you aren't reliant on a package manager being available in the future. For library developers, regardless of versioning scheme you need to avoid ANY breaking changes whatsoever. Instead of changing an existing function, introduce a new one so that your downstream users can still access the old behavior while keeping up to date. Trust me when I say this will be much easier most of the time than patching old versions with security updates and bug fixes.


API is API. Either a library implements it or not. There is no room for "just this small API change uwu" in large-scale development.


That way the land of "n.0.0" versioning lies, where only the major is ever incremented, even for changes that won't affect anyone because the code that would be incompatible hasn't been written yet.

At least for typed languages where an incompatibility clearly caught at compile time is usually preferable over a murky "technically it's still the same API, but..." I'd rather have separate numbers for major incompatibility/minor incompatibility.


Incompatibilities happen, APIs evolve. It's a fact of life. Semver helps communicate those incompatibilities to people who care about it.

If you don't want to freeze your API, then just use 0.X.Y, everyone will understand that they need to do regular maintenance if they want to use your code.

But please, I beg you, do not use version 1+ if you're not planning to keep the compatibility promise.


APIs evolve, sometimes in small steps and sometimes with major changes. Pedantic semver fails at communicating that difference.


What difference does semver fail to communicate, exactly?


Well, that's clearly not the way Go operates. Go makes incompatible changes between minor releases; they just don't break type signatures. For example, debug/pe ImportedLibraries(), which is supposed to return all the libraries that a module imports, was stubbed out to return "nil, nil" in a minor release [1]. This is clearly an incompatible change, but as it didn't cause code to fail to compile the Go team deemed it semver compatible.

Edit: Apparently this is wrong! See replies below.

[1]: https://fasterthanli.me/articles/abstracting-away-correctnes...


You're not wrong with your point, even if your example is. Go has made many breaking changes that still kept compiling, and compiling seems to be the thing they care about the most. Cribbing from a previous comment I've made... https://news.ycombinator.com/item?id=29763324

1. go 1.9 adding monotonic clock readings in a breaking way, i.e. this program changed output from 1.8 to 1.9: https://go.dev/play/p/Mi6cGCPd0rS

2. net/http started defaulting to http/2 for the same methods, an obviously breaking change

3. go1.17 switched to silently truncating a lot of query strings https://go.dev/play/p/azODBvkb-zK

4. Check out 'PreferServerCipherSuites' on tls.Config. https://pkg.go.dev/crypto/tls#Config ; of course that was a breaking change.

There's a few other things like this littered throughout the go stdlib, and I've personally hit far more breaking changes in Go's stdlib than the "go 1 compatibility promise" would have you expect.


> go 1.9 adding monotonic clock readings in a breaking way, i.e. this program changed output from 1.8 to 1.9: https://go.dev/play/p/Mi6cGCPd0rS

That's not a breaking change, if you were comparing Times with just "==" before 1.9, your program was broken as well:

From the 1.8 source [1]:

  // Equal reports whether t and u represent the same time instant.
  // Two times can be equal even if they are in different locations.
  // For example, 6:00 +0200 CEST and 4:00 UTC are Equal.
  // Do not use == with Time values.
  func (t Time) Equal(u Time) bool {
   return t.sec == u.sec && t.nsec == u.nsec
  }
[1]: https://cs.opensource.google/go/go/+/refs/tags/go1.8:src/tim...


It was not until go1.8 that they added the warning to not use '=='. In go 1.7, it simply told you that == vs Equal had different semantics, see https://cs.opensource.google/go/go/+/refs/tags/go1.7:src/tim...

There are many cases where you know that your times are in the same timezone, or you don't mind different timezones comparing as different, and so '==' used to work correctly quite often.

My program was clearly not broken in 1.7, when I followed the documentation "Equal ignores location, == uses location", and things worked well.

My program became counter to their documentation in 1.8. My program broke in 1.9.

I don't see how that is not a breaking change.


Yeah, you're right. I didn't think to check previous versions.

It seems to me like a flaw in the language that "==" can compare unexported struct fields.

Looks like it's possible to disallow comparing structs with "==" at compile time: https://go.dev/play/p/BfM6sDxlTq9. Not ideal, but better than nothing I suppose.


> Looks like it's possible to disallow comparing structs with "==" at compile time.

But of course, then such structs cannot be used as map keys: https://go.dev/play/p/JXzqHPInJ-G.


Thanks, I knew I had seen other examples other than the one I cited but had forgotten what they were.


It's worth noting that the author of that article was mistaken, there was likely some other issue with their software than what they described here. ImportedLibraries() in the pe package has never done anything other than returned nil, nil. This wasn't changed in a minor release. You can browse the source history here: https://github.com/golang/go/blame/master/src/debug/pe/file....


For example, debug/pe ImportedLibraries(), which is supposed to return all the libraries that a module imports, was stubbed out to return "nil, nil" in a minor release [1]

I just looked at the Git history and this is plain false. It already looked that way when the big source tree move (src/pkg/ -> src/) was done in 2014. Tracing it back further (to before Go 1.0 times, when there wasn't even a builtin error interface yet and the function returned os.Error), ImportedLibraries was *never* implemented in debug/pe.


I think taking a highly abstract definition of backcompat is not useful. We need a practical definition of back compat. If there are no (or effectively no) downstream consequences of a change, it is clearly backcompat. If there are some downstream consequences, you get into judgment call territory, but it still may be worth it. We cannot create a perfect universal rule here, and Amos is a fool for holding that standard so rigidly.


There's an extra dimension here though - support. Projects don't have unlimited resources which in majority of the cases means that only one major version is live.

For downstream consumers that gives 2 options: get stuck on an old version silently sometimes, or deal with an occasional breakage during usual dependencies updates. If the old version is used for talking to some external service, you will break one day.


Good point.

However, in case that two versions are so incompatible that both sides of communication channel need to update, then I think it's fair to place responsibility on the service writers to notify their users (through deprecation warnings, documentation, chat, etc.) of the change.


This is the answer. It's all internal, he says in his case. They know what they're doing with their own stuff. Staying on v0 is just another signifier that it's one of their internal things, they need to handle specially.


>> Due to our size, we don’t need any kind of backwards compatibility, we just update everything.

> Then just use 0.y.z versions and be done with it?

FWIW, I also work in an organization that thinks of libraries this way, and we've found success and simplicity in versioning (for production) our Go libraries as 0.X.0 where X is just a monotonically increasing number generated by the build pipeline.



Yep! I think it's fair to needle open source software, but it absolutely makes sense for a lot of internal development to adopt this sort of versioning policy.


> Then just use 0.y.z versions and be done with it?

Yeah, that would work, except a lot of people read 0.x.y versions as "alpha quality". Regardless of the actual code quality.


Is this a problem inside a small company though? I would expect there to be much better signals about how alpha-ish a library is in that setting (i.e. talking to your coworkers).


Funny you should ask. Yes, it can be when you use third party software and people have version number hangups.


Version number hangups are indeed a problem, I don’t mean to suggest my organization has escaped them. But if you can wade through those successfully, the technical solution itself often does make sense.


This is true and unfortunate, but I think the engineering value of meaningful versions is important and I expect that with enough time people will adapt to understanding a different meaning of 0.x.

And besides, people are right to understand 0.x is risky b.c you are not guaranteeing backwards compatibility.


If it is changing all the time, then it is "alpha quality."


If you expect a certain version to be valuable long term, promote it to Z.0 version. It's OK to to have AwesomeButRapidlyChangingLibrary-2022.03.0 branched from 0.y.z.


Or, as some people recommend, skip directly to V2 as the first version.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: