There are lots of things that could be said about the Java culture, but I think that in the case you're confronted with right now, there are a few significant aspects:
- Library code is written once but used much more often. While it's nice to minimize the overhead of writing the library, it's probably more worthwhile in the long run to write in a way that minimizes the overhead of using the library.
- That means that self-documenting types are great: method names help make it clear what's happening and what you're getting out of an object.
- Static typing is a very useful tool for eliminating certain classes of errors. It certainly doesn't fix everything (people like to joke about Haskell that once you get the type system to accept your code, it's probably correct), but it makes it very easy to make certain kinds of wrong things impossible.
- Writing library code is about specifying contracts. Defining interfaces for your argument and result types makes the boundaries of your contracts more clearly defined. If something accepts or produces a tuple, there's no saying whether it's the kind of tuple you should actually receive or produce, and there's very little in the way of constraints on such a generic type (does it even have the right number of elements? are they of the type that you were expecting?).
"Struct" classes with fields
As other answers have mentioned, you can just use a class with public fields. If you make these final, then you get an immutable class, and you'd initialize them with the constructor:
class ParseResult0 {
public final long millis;
public final boolean isSeconds;
public final boolean isLessThanOneMilli;
public ParseResult0(long millis, boolean isSeconds, boolean isLessThanOneMilli) {
this.millis = millis;
this.isSeconds = isSeconds;
this.isLessThanOneMilli = isLessThanOneMilli;
}
}
Of course, this means that you're tied to a particular class, and anything that ever needs to produce or consume a parse result has to use this class. For some applications, that's fine. For others, that can cause some pain. Much Java code is about defining contracts, and that will typically take you into interfaces.
Another pitfall is that with a class based approach, you're exposing fields and all of those fields must have values. E.g., isSeconds and millis always have to have some value, even if isLessThanOneMilli is true. What should the interpretation of the value of the millis field be when isLessThanOneMilli is true?
"Structures" as Interfaces
With the static methods allowed in interfaces, it's actually relatively easy to create immutable types without a whole lot of syntactic overhead. For instance, I might implement the kind of result structure you're talking about as something like this:
interface ParseResult {
long getMillis();
boolean isSeconds();
boolean isLessThanOneMilli();
static ParseResult from(long millis, boolean isSeconds, boolean isLessThanOneMill) {
return new ParseResult() {
@Override
public boolean isSeconds() {
return isSeconds;
}
@Override
public boolean isLessThanOneMilli() {
return isLessThanOneMill;
}
@Override
public long getMillis() {
return millis;
}
};
}
}
That's still a lot of boilerplate, I absolutely agree, but there are a couple of benefits, too, and I think those get start to answer some of your main questions.
With a structure like this parse result, the contract of your parser is very clearly defined. In Python, one tuple isn't really distinct from another tuple. In Java, static typing is available, so we already rule out certain classes of errors. For instance, if you're returning a tuple in Python, and you want to return the tuple (millis, isSeconds, isLessThanOneMilli), you can accidentally do:
return (true, 500, false)
when you meant:
return (500, true, false)
With this kind of Java interface, you can't compile:
return ParseResult.from(true, 500, false);
at all. You have to do:
return ParseResult.from(500, true, false);
That's a benefit of statically typed languages in general.
This approach also starts to give you the ability to restrict what values you can get. For instance, when calling getMillis(), you could check whether isLessThanOneMilli() is true, and if it is, throw an IllegalStateException (for instance), since there's no meaningful value of millis in that case.
Making it Hard to Do the Wrong Thing
In the interface example above, you still have the problem that you could accidentally swap the isSeconds and isLessThanOneMilli arguments, though, since they have the same type.
In practice, you really might want to make use of TimeUnit and duration, so that you'd have a result like:
interface Duration {
TimeUnit getTimeUnit();
long getDuration();
static Duration from(TimeUnit unit, long duration) {
return new Duration() {
@Override
public TimeUnit getTimeUnit() {
return unit;
}
@Override
public long getDuration() {
return duration;
}
};
}
}
interface ParseResult2 {
boolean isLessThanOneMilli();
Duration getDuration();
static ParseResult2 from(TimeUnit unit, long duration) {
Duration d = Duration.from(unit, duration);
return new ParseResult2() {
@Override
public boolean isLessThanOneMilli() {
return false;
}
@Override
public Duration getDuration() {
return d;
}
};
}
static ParseResult2 lessThanOneMilli() {
return new ParseResult2() {
@Override
public boolean isLessThanOneMilli() {
return true;
}
@Override
public Duration getDuration() {
throw new IllegalStateException();
}
};
}
}
That's getting to be a lot more code, but you only need to write it once, and (assuming you've properly documented things), the people who end up using your code don't have to guess at what the result means, and can't accidentally do things like result[0]
when they mean result[1]
. You still get to create instances pretty succinctly, and getting data out of them isn't all that hard either:
ParseResult2 x = ParseResult2.from(TimeUnit.MILLISECONDS, 32);
ParseResult2 y = ParseResult2.lessThanOneMilli();
Note that you could actually do something like this with the class based approach, too. Just specify constructors for the different cases. You still have the issue of what to initialize the other fields to, though, and you can't prevent access to them.
Another answer mentioned that the enterprise-type nature of Java means that much of the time, you're composing other libraries that already exist, or writing libraries for other people to use. Your public API shouldn't require lots of time consulting the documentation to decipher result types if it can be avoided.
You only write these structures once, but you create them many times, so you still do want that concise creation (which you get). The static typing makes sure that the data you're getting out of them is what you expect.
Now, all that said, there are still places where simple tuples or lists can make a lot of sense. There may be less overhead in returning an array of something, and if that's the case (and that overhead is significant, which you'd determine with profiling), then using a simple array of values internally may make lots of sense. Your public API should still probably have clearly defined types.