Go and Java: Rethinking Type Safety for the Pragmatic Age
rohan.ga
rohan.ga
In Java all of your user defined types are, alas, represented only referentially and a null reference is always possible, so even if there is no such thing as an invalid Goose, your variable of type "Goose" may just be null anyway.
In Go, it's possible to actually make a Goose and have it as just a local variable living on your stack, but, the language insists that anybody can conjure one into existence despite having no basis for doing so, the resulting Goose is at best now in some invalid "null" state.
To me, avoiding the Billion Dollar Mistake in new software is table stakes. Even at work, in the relatively boring C# language, we can avert the Billion Dollar Mistake in new codebases and gradually wean old ones off this idea. The CLR is hostile to properly fixing the mistake eco-system wide, but this is a marked improvement.
Java is well on its way to alleviating the problem. [1]
If you have something like
Map<Integer, Optional<Integer>> map;
var foo = map.get(1);
if `1` isn't present in the map then `foo` has to be set to something. It will be `null` in this case.If the new null restricted stuff makes it in, you can express this as
Map<Integer, Optional<Integer>!> map;
var foo = map.get(1);
and yet, `foo` will still end up being `null` in that case (I assume) because something has to come back if `1` isn't present.At best, that will prevent you from doing `map.put(1, null);`
Am I missing something?
To be more clear:
Map<K, V>.get(foo)
returns a `V`, not an `Optional<V>`. If your `V` is `Optional<WrappedV>`, cool, but that doesn't change that `Map` either finds or doesn't find a V. Optional<V> get(K)
sounds like an 'obvious' addition, though also I've found that when something 'obvious' isn't added to Java since 1.7, the explanation proffered makes sense to me.If you had something like:
var x = map.get(k);
The type of `x` could not be inferred.The best they could hope for is a signature like `Optional<V> getOpt(K);`. Which wouldn't be terribly bad as Java currently has an annoying get definition in the `Map` interface because it predates generics (The current signature is `V get(Object)`).
Regardless, if they do want to change the interface, it'd probably be best if they waited for Valhalla to land. One of the problems with `Optional` as it stands is it creates a load of GC pressure. If `Optional` is converted into a value class then there will be effectively no overhead to returning it everywhere.
In Dart[1] and Vlang[2], for example, "non-nullable" is the default. Vlang doesn't allow null or nil to be used, unless code is marked as unsafe, and primarily only for compatibility with languages that do (like C). In Dart, nullable variables have `?` added to the end of them.
[1]: https://en.wikipedia.org/wiki/Dart_(programming_language)
If you really don't want people instantiating instances of a type on their own, can't you just make the type private but make a factory for the type public?
Perfectly possible to write beautiful code. Alas, hell is other coders, or something.
Edit hot take: I wish Go would just have been Pascal/Delphi rebranded. All language discussions just ignore we had a contender against C for decades.
If you say it’s too weird, don’t come dragging in Go in the same breath, thanks.
"Yes", but really, no. It is syntactically valid to write a function that returns an unexported type, and you can technically get an instance of that unexported type in your other package by using := to assign it to a variable. However, you can not name the type in the other packages at all. So, you can not say "var x mypkg.unexportedType", even if you can "x := mypkg.ReturnUnexportedType()". You can not put it in a struct/channel/slice/map/whatever, since you can't name it and there's no equivalent of := for types. You can't refactor the function that contains the "x" in any way that involves passing it to a new function because the type signature for the new function would have to mention the unexported type, which it can't do, and there is no inference for function types that would let you elide it. Reflect restrictions on unexported types are still in effect so even trying to put it in an "any" isn't really all that useful.
Basically, it's useless, even though it's just barely sort of valid, because it's much more than just creation that is blocked. In practice you have to export types you want users to be able to assign to, put in structs, etc., and if they are exported, their zero values can be created by any package that imports them. One of the standard linters in golangci-lint will warn you if you accidentally write an unexported type into an exported type or value, because you didn't mean that, even if you thought you did.
You can write an interface that you export, and then return an unexported data type that conforms to that interface. However, you then can't prevent someone from having a nil instance of that interface, so in terms of preventing invalid data, this doesn't really do anything useful. This is more useful for manipulating package documentation by not exporting types unnecessarily and having to write docs for them (or, if you don't write docs, having them appear in the godoc) then as access control.
Defensive programming is generally a good approach regardless of domain or language used, and if used well you don't have to worry about nulls in Java, at least not in your own code.
In other words, the cognitive overhead you spend on worrying about nulls (however you do that), programmers in other languages spend on other things.
Developers are not perfect and the footguns in languages have a cumulative effect. Any one thing isn’t necessarily a big deal but when you have multiple interacting footguns (always check for nil, nil interfaces don’t actually equal nil, default zero values need to be sane even when they make no sense, etc.) both the surface area for errors and the level of care needed to avoid them increases dramatically.
The point is rather that, because this can be automated, it ought to be automated.
The same could be said of automated tests ("in well-maintained codebases contributors manually run through a large suite of tests to ensure there are no regressions.")
Of course you could be "defensive" by adding an if-statement every single time you use any object. But that would mean an order of magnitude more lines of code, and even then it would be easy to miss cases
I guess that's a fine definition in theory, but every codebase that I've ever worked in that was written in a language with null references has had null pointer exceptions as one of the top recurring bugs. Maybe that means all my codebases have been poorly maintained, but if so that's a very good reason to design languages that require less maintenance.
You can have a goose (unepxorted) that is created via NewGoose() (exported - really a constructor). Of course within a package there is nothing to stop you doing the wrong thing. I really like Go. But I do find myself occasionally writing overly verbose types just to avoid this issue. Which, if I’m honest with myself, is a bit silly.
There’s a good counter argument to using to unepxorted types like this below.
Having said that I cannot understand where the idea that "the [Go] libraries and ecosystem are so much better" comes from. The average quality of both the JDK libs and the popular 3p libs (Jackson, Jooq, even ...Spring) are one of the things I miss most about writing for the JVM and I'm yet to come across another ecosystem that comes close.
I also generally am writing REST APIs and doing simple "enterprisey" stuff.
Java: Arrays.stream(nums).map(n -> n * n).toArray();
Kotlin: nums.map { it * it }
Python: list(map(lambda x: x*2, nums))
C#: nums.Select(n => n * n).ToArray();
Ruby: nums.map { |n| n*2 }
Rust: nums.iter().map(|&n| n * n).collect();
Perl: map { $_ * $_ } @nums;Personally, when I'm solving a problem, I like to reach for the pre-established universally understood pattern that's built into my language. Rather than using a language that doesn't have any of those things and requires me to create my own, worse, implementation that's unique to my program (or use a random 3rd-party module of dubious quality for something basic).
> requires me to create my own
Yes, how many string contains methods are there out there, as the result of everyone having to write their own.
I feel like we've jumped out of the frying pan of the 90s' OOP craze, directly into the fire of doing the same cargo cult behavior with functional programming. Always avoid loops, always use higher-order functions. It just makes sense. If you are not provided with the shotgun spread of higher-order functions out of the box, be frustrated.
I vaguely recall reactions to Haskell in the late 90s where vitriol was spewed over the lack of inheritance and classes.
Go now has slices.Contains
It has enums, and if you want exhaustive checking, use golangci-lint (as every good gopher should be already, considering the language ensured tooling would be easy to write) https://golangci-lint.run/usage/linters/#exhaustive
> You want functional constructs (map, filter), nope.
There are libraries for map/filter (just make sure you use one with deforestation)
> You want string array contains, nope.
Raw Java is Pretty Close to being "out of the box" OK for "REST, enterprisey" stuff.
The built in HTTP server is usable and functional. Of course TLS is built in. JDBC is built in (need a driver). While XML is built in, JSON is not. Logging is built in. JMX for monitoring is built in (lots of things can talk to JMX).
Are these all "top tier" feature rich implementations? No. But they're completely usable. With some thin veneers you can make them richer and more friendly. I've been using my own trivial Logger wrapper for years (mostly to support varargs).
If you're willing to take a bit of a step, JAX-RS on Java SE works. That "single" dependency just knocks it out of the park in terms of "enterprisey" REST services.
No container, no "micro profile", just JAX-RS (but you still need a JSON library). JAX-RS really elevates the game. It'll even run on the stock HTTP server.
Add "just one more" with JPA, and you get not just the whole ORM, you also get a "free" database connection pool (otherwise, an OTS connection pool would be a nice addition).
All bottled up into a simple deployable jar, no dependencies outside of a compatible JDK. No dockers, no containers, just a jar and a JDK, a JDK that can be installed anywhere (just set JAVA_HOME and put the .../bin on your path). Drag and drop. systemd fixed the need for crafty service scripts -- it can just run the jar.
JAX-RS Jersey, JPA EclipseLink, Jackson (for JSON), and the JDK is just crazy capable tool set. You can also do MVC web stuff with Jersey. Jersey comes with Validation as well. The HK2 runtime will let you do your own injection if that's your thing (not as nice as CDI, but it's "free" with Jersey).
But, alone, (plus Jackson), the JDK will let you do anything you want, just need to write some routing logic.
Reflecting on my own reasons : it comes down to that I'm not particularly fond of IntelliJ and even less fond in principle of being critically dependent on a language that is only so controlled by a single vendor, and has poor IDE support outside that vendor's IDE. If I loved IntelliJ I might not care.
However I also find that there is still impedance mismatch with Java and it's just enough that I always just prefer to accept the warts of Java rather than use a language that is less known and supported.
Ironically, the IntelliJ support for Kotlin is also kind of weak.
---
For the main point, Kotlin feels like something that would be anathema to a large group of Go devs who favor a small language. It's precisely the limits that viewpoint imposes that has meant Go is not my favorite language, but its clear that group is a big driver of Go's userbase.
Yes exactly - somehow internally it grates against my instinct that simpler is always better to have a bolt-on language that layers all its own classes and APIs on top of a host language. I feel like that just can't be better in the long run and it if it is better now it can only be a temporary state of affairs.
But then, I also like Groovy which is kind of the ultimate version of a bolt on language, so maybe I am just ex post facto rationalising my internal preferences.
The author and I clearly have different definitions of simple. Indeed, I believe that the syntax bloat of Java, which is not a source of simplicity, is a significant source of complexity and causes overengineered frameworks to proliferate in the ecosystem to make up for the language defects. Moreover, Java is full of features and keeps adding new ones every six months. Every new feature is more complexity, possibly added for a good reason, but nevertheless increasing the complexity of the language.
Kotlin (from the perspective of this blog post) stands above both Java and Go here because it's type system is even more expressive, it has null safety and it's less verbose than both.
I love Kotlin because it's unapologetically pragmatic, it largely forgoes ideology and instead focuses on getting things done. Go and Java have ideologies and while I am more partial to the Java ideology over the Go one I would prefer to not have to subscribe to either.
Java is just the perfect home for type enthusiasts. You sit down to prototype some idea and you need to debate what kind of class structure it's going to need for the first 25 minutes - something really abstract or something more short term...are you really going to develop this idea into something big with all sorts of generic high level classes or are you going to be cheap and short about it? Are cats and dogs the same from the point of view of this system or not?
In both of them people love writing the kind of code that jumps all over the place and injects all sorts of dependencies and just looking at it, it's almost impossible to understand what anything is doing. There's no such thing as "TOO SOLID"
I admit I have a very jaded view. C/C++ have the freedom to put your source code wherever you like, compile it in parallel on a cluster or not and so on but then they are cursed to hell with the C preprocessor. Perhaps Turbo Pascal is nice?
Of course, in reality the isolation isn't perfect, which is how we got things like deterministic (slow) floating point and "write once, test everywhere".
Go does have a similar attitude, practically speaking, if you stick to the standard library. It's more like "cross-compile and run wherever".
I sometimes like to compare programming languages to vehicles. Java is perhaps the long haul truck and Go the smaller and more agile delivery van. The smaller van is more approriate for the things I work with for my own pleasure, but the truck has its places for the bigger enterprise jobs.
https://dart.dev/language/records
The frustrating thing is that it's so close to the functional features that Rust embraced (mainly that everything is an expression), but it just brushes against it. Like, with Python and Javascript, you know they're shit. Nobody expects to be able to write a switch that evaluates to a lambda or whatever in Python. But Dart is just so close to greatness but just misses!
They even added switch expressions recently, but with a different syntax to switch statements! So close!
Still, overall it's a great language and really deserves more market share. In particular the dev tooling is the best of any language I've used. The LSP server is instantaneous. Nothing else comes close.
Dart has had a challenging history. Most of the original designers of the language have moved on to other things. They had a very conservative vision for the language, basically a 50/50 blend of Java/JavaScript. Pretty close to TypeScript but with a much simpler type system and less nice JS interop.
That's not really what the world seems to want today, so the current language team is trying to evolve the language into something more modern. But doing that while being sensitive to minimizing user breakage and migration cost is a really hard problem.
We've been able to make a lot of progress: sound type system, non-nullable types, pattern matching, etc. But some stuff is a lot harder. I think most of us on the language team would prefer type annotations on the right like "var x: int", but that's a huge migration cost for relatively little value. Likewise, having everything be an expression would be really nice but is also a really difficult change to make.
I wonder if you could make a "syntax v2" kind of thing, so it's a totally new syntax that you opt into (e.g. by a pragma at the top of files, or globally) that gets converted into the same IR.
Probably only worth doing once though to fix all of those things. And as you say, that's a ton of work for marginal benefit.
The benefit would be real, but probably not large enough to outweigh that cost.
Path dependence rules everything.