Hate Speech: Java

etki
10 min readJan 21, 2021

Time to time I express my negative attitude towards Java and obviously asked back to explain what I think is utterly wrong with Java. This article is created to aggregate all of my critique so those conversations could go in a bit more speedy and DRY way.

Primitives and objects

There is no common ground for a value in Java. While the challenge itself is quite complex — target both reference and value types in a single way — it could be solved at least partially, just as C# does. C# mostly treats both kinds of types in a similar fashion, letting programmer not to know what happens under the hood and resorting to boxing in the very unavoidable cases (e.g. when method accepts object). Java both separates reference and value worlds and denies existence of the second one. As a programmer, one can’t define new value types, as if they don’t exist at all (yes, I know that things are changing, but they won’t settle in any near future), however, one have to constantly deal with boolean vs. Boolean and work with static analyzer to prevent NPEs when using the latter ones in conditions.

Denial of necessary features, which ends in stealing them when they’re already five years in C#

This part of rant targets two things at once: first, people designing language constantly reject adding features whole industry is already using, second, it is obvious that a lot of features come directly from C#, but in a pretty much poor state and with lag of several years, up to a decade. LINQ lead to birth of Stream APIs, introduction of var keyword, lambdas, native images (which are pretty much impossible to create if any library creates static variable of something that is not intended to be), half-way pattern matching, multiline strings (that’s not exactly a C# feature, but something every language should have), modules (which are just a poor mirroring of assemblies; also, they work, but at the same they don’t because of backward compatibility), adequate HTTP client and lots and lots of other things I currently can’t bring up in my memory.

But wait, where is as keyword? FFI that won’t require me to write C++? Await/async instead of CompletableFuture hell? TryParse methods that won’t throw redundant exception, refs? Null safety baked in language syntax? And, finally, where are type aliases so I could resolve name collisions on import level? Come on, they are available even in Scala, but their existence in Java is denied for over twenty years (with classic “it’s a feature, we know what’s better for you”).

There are just several features I know in Java that are not yet in C#.

  • Optional.
  • Diamond operator (which is not applicable when using var).
  • Path class (which I quite don’t like since it is coupled with local FS, thus making it’s usage for object storages semantically incorrect).
  • Finally, default interface methods arrived much later in C# than in Java, but now they’re finally in.

Yes, the type erasure

This is huge. No, one simply can’t even comprehend the whole effect of it. It’s not even just about how you write your code, it creeps in every aspect of Java program.

You can’t write generic exceptions. Why? Because you can’t distinguish them when catching.

try { ... }
catch (IllegalValueException<Integer> e) { ... }
catch (IllegalValueException<String> e) { ... }

You can’t define method(List<A> input) and method(List<B> input) (but method(ArrayList<B> input) would work — this is quite logical, but it contradicts example above), even though compiler is capable to distinguish them and write corresponding bytecode.

One can’t write <T> method(T… varargs) without having compiler warning. Unless you suppress it with SafeVarargs. But wait, suppressing warnings is always ultimately wrong, right? Not for Java:

@SafeVarargs
@SuppressWarnings("varargs")
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}

Now stop for a second. Comprehend it. This is the most used generic varargs method ever, and it thinks it is safe to ignore this thing. The whole pattern is broken from the very start. This warning is completely useless because it’s officially ignored, while it truly does point potential errors.

Class<T> argument everywhere. I won’t even start.

One can’t — yes, this is classic — do new T[], so instead of compiler doing that programmers are doing that by hand (yes-yes, .toArray(new T[])), making places which require that O(n) instead of O(1), should it be supported by language. And here we’re talking not about execution time, but human work time, which is magnitudes more expensive.

Finally, raw types. Did you know that Type<?> and Type are handled differently by compiler? And those people will tell us new features can’t be introduced because they would contradict legacy behavior.

Exceptions

The concept of checked and unchecked exceptions itself is not so bad, but:

a) Nobody cared to explain what’s the difference.
b) Nobody added the option to convert one to another, e.g. in my case InterruptedException is a non-addressable exception, how do I get away without unnecessary wrapping?
c) There is no additional support from language to lift up the regular burden, instead we have hackish Lombok with SneakyThrows .

As a result, we see enormous amount of exception swallowing in every library, turning the whole world to shit and endless debug sessions to find out what’s wrong.

Also, check out this beautiful inference that exploded my brains once:

public class Main {
public static void uh() {
try {

} catch (Exception e) {
throw e;
}
}

// <no errors>

public static void oh() {
try {
throw new RuntimeException();
} catch (Exception e) {
throw e;
}
}

// <no errors>

public static void snap() {
try {
throw new Exception();
} catch (Exception e) {
throw e;
}
}

// /tmp/Main.java:8: error: unreported exception Exception; must be caught or declared to be thrown
// throw e;
// ^
// 1 error
}

Weird type system

List<? extends T> is not castable to List<T>, while, you know, with type erasure it’s the very same thing. capture of ? is not an ?. ? is not an Object. Unsound. JEP for inferring types from chained methods (Container<T> = builder().genericValue(value).build()) was added years ago and still not there. This wouldn’t be such a pain should generic signature be added between method name and parenthesis — but again, this is a C# feature, not Javas. And, the worst thing, ? extends Generic<? extends E>, while most of the times this extends may be propagated and there could be just another keyword for that.

I do understand that I don’t know about type system enough to know why some of the decisions have been made, but in the end I’m quite sure many of them could be avoided.

“Agnostic” infrastructure serves the language

Formally, Java and JVM are completely different things. In reality, JVM serves Java instead of being developed separately, up to — of course! — having same type erasure flaw.

Some might argue that JVM is a very sophisticated piece of software. For example, there is scalarization technique that is in fact even not widely known. This is beautiful, right?

No.

Because it simply there to fix inability of Java to handle custom value types in the first place. It. Fixes. The. Essentially. Broken. Language. The structure types from other languages do this automatically with much less burden.

This thing has it’s roots everywhere, just like type erasure. I literally opened first HotSpot source code file I found:

/**
* List of core modules for which we search for shared libraries.
*/
static const char* modules[] = {
"java.base",
"java.logging",
"jdk.compiler",
"jdk.internal.vm.ci",
"jdk.internal.vm.compiler"
};

Can anybody in the world explain why language-agnostic virtual machine has references to particular language in it’s source code? It is built for byte code, not Java code. There should be something like pluggable mechanism for that, I might not being running Java-derived thing on it at all.

Derived languages

Every language that spins off atop of JVM is broken because it takes all the flaws JVM (and sometimes Java, which is the case for Scala) introduces. Kotlin went native, but it still does have type erasure and you have to write that reified keyword that also works only in inline functions. Completely different runtime. Completely different language. Same shit.

Broken backwards compatibility

The reasoning for not changing something to the better variant is backward compatibility, so programs written for Java 1.0 would still run. Ok, the idea is good, but you know what? This compatibility has already been broken many times. “Illegal reflective access”, which will transition from warning to error one day, Cassandra 2.2.x can’t start on JDK 9+ because some thread attribute is gone, and a lot more. But saint backward compatibility prevents us from creating parallel non-type-erased type system (<reified T>, just like Kotlin), which will support both legacy and new code. And you know what’s absolutely hilarious? C# did introduce non-type-erased generics, and Java still lagging behind, even though C# started its way much later than Java.

Also, we had Java 1.8. Given all the time 9 version was developed, why not go with new, breaking 2.0 instead?

Fantastic, unbelievable standard class library

Java Class Library should have “M for Mature” sign on it. Some places will just make you vomit. I’ll just put several examples below.

I think I’ll won’t use braces where they are usually used and then wrap return in else where it’s absolutely unnecessary.

public final class Optional<T> {
public<U> Optional<U> map(Function<? super T, ? extends U> mapper) {
Objects.requireNonNull(mapper);
if (!isPresent())
return empty();
else {
return Optional.ofNullable(mapper.apply(value));
}
}
}

Found out there’s a radix in Long.valueOf() only in JDK 9

Also check that beautiful validation.

public static UUID fromString(String name) {
String[] components = name.split("-");
if (components.length != 5)
throw new IllegalArgumentException("Invalid UUID string: "+name);
for (int i=0; i<5; i++)
components[i] = "0x"+components[i];

long mostSigBits = Long.decode(components[0]).longValue();
mostSigBits <<= 16;
mostSigBits |= Long.decode(components[1]).longValue();
mostSigBits <<= 16;
mostSigBits |= Long.decode(components[2]).longValue();

long leastSigBits = Long.decode(components[3]).longValue();
leastSigBits <<= 48;
leastSigBits |= Long.decode(components[4]).longValue();

return new UUID(mostSigBits, leastSigBits);
}

I need single instance of that so I’ll make it an Enum

/**
* Compares {@link Comparable} objects in natural order.
*
* @see Comparable
*/
enum NaturalOrderComparator implements Comparator<Comparable<Object>> {
INSTANCE;

@Override
public int compare(Comparable<Object> c1, Comparable<Object> c2) {
return c1.compareTo(c2);
}

@Override
public Comparator<Comparable<Object>> reversed() {
return Comparator.reverseOrder();
}
}

My favorite one is CompletableFuture. How the hell one reads that? What is the problem writing down sensible variable names? Why using labels? Why not put every variable declaration on separate line? Why use mode <= 0 instead of mode <= SYNC?

final CompletableFuture<V> tryFire(int mode) {
CompletableFuture<V> d; CompletableFuture<T> a;
Object r; Throwable x; Function<? super T,? extends V> f;
if ((d = dep) == null || (f = fn) == null
|| (a = src) == null || (r = a.result) == null)
return null;
tryComplete: if (d.result == null) {
if (r instanceof AltResult) {
if ((x = ((AltResult)r).ex) != null) {
d.completeThrowable(x, r);
break tryComplete;
}
r = null;
}
try {
if (mode <= 0 && !claim())
return null;
else {
@SuppressWarnings("unchecked") T t = (T) r;
d.completeValue(f.apply(t));
}
} catch (Throwable ex) {
d.completeThrowable(ex);
}
}
dep = null; src = null; fn = null;
return d.postFire(a, mode);
}

Also this. How does one check file existence?

public static boolean notExists(Path path, LinkOption... options) {
try {
if (followLinks(options)) {
provider(path).checkAccess(path);
} else {
// attempt to read attributes without following links
readAttributes(path, BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
}
// file exists
return false;
} catch (NoSuchFileException x) {
// file confirmed not to exist
return true;
} catch (IOException x) {
return false;
}
}

Sure, if there’s IOException that certainly means there is no file. Also comment following if (followLinks) else is a pure beauty.

public final class Optional<T> {
public Optional<T> or(Supplier<? extends Optional<? extends T>> supplier) {
Objects.requireNonNull(supplier);
if (isPresent()) {
return this;
} else {
@SuppressWarnings("unchecked")
Optional<T> r = (Optional<T>) supplier.get();
return Objects.requireNonNull(r);
}
}
}

This is more of a nitpick, but how something can extend final class?

private class CompileQueue extends ThreadPoolExecutor

How the hell queue is an executor?

Breaking it’s own rules

So we, as developers, should be dealing with all stuff like checked exceptions and heap pollution properly. But if you are expecting the same from standard library, then ha-ha, this is only for downstream developers. I’ve already mentioned SafeVarargs, but check out this beauty:

public final class Files {
/**
* Convert a Closeable to a Runnable by converting checked IOException
* to UncheckedIOException
*/
private static Runnable asUncheckedRunnable(Closeable c) {
return () -> {
try {
c.close();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
}
}

We’ve added checked exceptions concept to completely ignore it

* @throws ClassCastException if the collection contains elements that are
* not <i>mutually comparable</i> (for example, strings and
* integers).

I beg you pardon? I’m not casting anything, why ClassCastException?

public <U> Optional<U> flatMap(Function<? super T, ? extends Optional<? extends U>> mapper) {
Objects.requireNonNull(mapper);
if (!isPresent()) {
return empty();
} else {
@SuppressWarnings("unchecked")
Optional<U> r = (Optional<U>) mapper.apply(value);
return Objects.requireNonNull(r);
}
}

Remember my rant about ? extends T not being T?

private void enqueueMethod(AOTCompiledClass aotClass, ResolvedJavaMethod method) {
AOTCompilationTask task = new AOTCompilationTask(main, graalOptions, aotClass, method, backend);
try {
compileQueue.execute(task);
} catch (RejectedExecutionException e) {
e.printStackTrace();
}
}

static void logCompilation(String methodName, String message) {
LogPrinter.writeLog(message + " " + methodName);
}

Print stack trace and do nothing. It is OK for method not to be compiled, we will enqueue it later. But it is not OK, so we print a stack trace. No, we don’t need to write a comprehensible message here or log it. Just a stack trace.

Reverse DNS names that don’t support dash symbol

It’s simple. Either it’s a case-insensitive DNS name that supports dash symbol, either give us an option to use camelCase or PascalCase names. But if it’s officially reverse DNS name, why not to support dash symbol? Java doesn’t support subtraction outside of types, it’s surely possible to distinguish two usages. And also I’m fed up with requestmodifier names, I just want my code to be clean and shiny.

The good parts

However, I want to end this rant with something pleasant. I can’t and don’t want to deny that Java ecosystem is really big, that JVM has so many tunable variables that nobody probably knows all of them, that several GC choices is good, that slow deprecation of features is also a good thing, and that compiler interface is something uncommon and beautiful. I’m quite sure Java influenced C# as well, and many C# features were born to compete with Java (i.e. they wouldn’t be there if there wouldn’t be a strong competitor on the horizon). PHP, with all of it’s drawbacks, inherited a lot (probably, all the good parts) from Java — the things that make it more disciplined than other script languages.

Java is essentially broken, yes. But it is still much more than just usable and has undergone a lot of deep performance tuning.

--

--

etki

I still bear a russian citizenship with no plans to prolong the contract, if that matters