Hacker News new | ask | show | jobs
by btilly 665 days ago
This is great for avoiding conflicts when you try to get your project running.

It sucks when there is a vulnerability in a particular library, and you're trying to track all of the ways in which that vulnerable code is being pulled into your project.

My preference is to force the conflict up front by saying that you can't import conflicting versions. This creates a constant stream of small problems, but avoids really big ones later. However I absolutely understand why a lot of people prefer it the other way around.

3 comments

   cargo tree -i log@0.3.9
will show which dependencies require this particular version of log, and how they are transitively related to the main package. In this case, you would clearly see that the out-of-date dependency comes from package "b".

There are equivalents for must other package managers that take this approach, and I've never found this a problem in practice.

Of course, you still need to know that there's a vulnerability there in the first place, but that's why tools like NPM often integrate with vulnerability scanners so that they can check your dependencies as you install them.

And tools like cargo audit or cargo deny can check your build tree for CVE, and suggest what to update.
That’s nowhere near as terrible as not being able to resolve a conflict between incompatible versions. Like half of your project can’t use Guava X but another half can’t use Guava Y, and there is no common version that works. We ran into compatibility problems with our big Java project many times and wasted months on attempting things like jar shading or classloaders. At the end of the day we use shading but that comes with its own set of annoyances like increasing the build times and allowing people to occasionally import the wrong version of library (eg. shaded instead of non-shaded). The bigger the project the more likely you’re going to hit this, and the lack of support for feature-gating dependencies in the Java ecosystem doesn’t help.
Go got this right: you want an incompatible version, you have to use a different import path. Then you can only pick one version (which is deterministically the lowest possible version) for a certain import path, not a hundred different versions.

Also forces people to actually take backwards compatibility seriously.

I'm not surprised. Go's design is heavily informed by what does and does not cause cascading design problems in software engineering at scale. These practical concerns are very different from the kinds of issues that academia had been focused on. But practical solutions to practical problems is central to Go's popularity.
No one asked about Go here. And no, it didn’t, it’s the same PITA as in Java or maybe even worse because there are no workarounds like classloading or shading. You have no control over the transitive dependencies. The only thing you can do if there’s a conflict is asking the author to fix one of the conflicting libraries.
You can force transitive dependency to the version you want. Just do a go get and your entire project will use version x.y.z.

And I believe Go only using one version is the best solution. It avoids many problems.

The problem happens when the version that works with all the other libraries in the project does not exist.

And btw: you can force any transitive version in gradle/maven/npm/cargo as well, that’s not a feature unique to Go.

If someone talks about a problem I’ll damn well explain other people’s solutions as I please. And no, you resolve conflicts by not having conflicts in the first place.
> And no, you resolve conflicts by not having conflicts in the first place.

That means you can't use library A and unrelated library B together in the same project, even though you can use A alone and can use B alone. That's lack of orthogonality.

No, you seriously discourage libraries from breaking compatibility by removing the possibility to hide behind different pinned versions and version ranges.
But surely you can still run into the same issue:

I import a@1 and b@1 a@1 transitively depends on c@1 b@1 transitively depends on c@2

Even with different import paths, I still have two different versions of c in my codebase. It'll just be that one of them is imported as "c" and the other will be imported as "c/v2" - but you don't need to worry about that, because that's happening in transitive dependencies that you're not writing.

You still have the same issue of needing to keep track of all the different versions that can exist in your codebase.

It’s c and c/v2, not c@1.0.0, c@1.0.5, c@1.0.10, c@1.1.3, c@2.0.0, c@2.3.1, ... Each necessary because packages in the middle have decided to pin versions or add upper bounds to work around bugs. That’s a huge difference.
FWIW, I've just pulled up a pretty large project I work on using NPM, and almost all of the duplicate dependencies had different major versions. Most of the ones that had the same major version were 0.x dependencies with different minor versions.

So I'm still not convinced that Go's approach is materially different here - certainly in terms of the practical output, NPM does a good job of ensuring that the fewest number of different versions will get installed for each dependency.

You forgot the part where npm people release new major versions for very little reason all the time, because there’s nothing stopping them. Go authors on the other hand are generally really reluctant to change to a new path. Go to a relatively large go codebase and count the v2s. Then do v3.

Coming back to a midsized JavaScript codebase after a few months and trying to upgrade to new major versions of things have always been a shitshow.

Backwards compatibility is more difficult in Rust for many reasons. For example, you can't add a new item to an enum without creating missing-case errors everywhere it is used.
That's a true effect, although I'd question whether it makes it harder or easier for things to be backwards compatible. I use Rust because I trust it to throw up a bunch of errors when I make changes; if I handle all the cases of an enum somewhere, and suddenly there's a new enum variant, the answer is probably that I need to handle the new variant there too.
Adding an enum variant can be backwards incompatible just as easily in languages that don't do this, you just don't get to see the error at compile time.
You can't add a method in Go without causing new interfaces to match, which breaks at runtime.
You can use #[non_exhaustive] if you want to avoid this.
Right. This even tidily forbids the exhaustive matching for other people's code (ie requires them to write a default) using your crate but still allows it within the crate, reasoning that you should know which values you added even if you never promise an exhaustive list to your users.