Есть много вещей, которые можно сказать о культуре Java, но я думаю, что в случае, если вы столкнулись с этим прямо сейчас, есть несколько важных аспектов:
- Библиотечный код пишется один раз, но используется гораздо чаще. Хотя приятно минимизировать накладные расходы на написание библиотеки, в долгосрочной перспективе, вероятно, более целесообразно писать так, чтобы минимизировать накладные расходы на использование библиотеки.
- Это означает, что самодокументируемые типы великолепны: имена методов помогают понять, что происходит и что вы получаете от объекта.
- Статическая типизация является очень полезным инструментом для устранения определенных классов ошибок. Это, конечно, не все исправляет (людям нравится шутить по поводу того, что Haskell, как только вы заставите систему типов принимать ваш код, это, вероятно, правильно), но очень легко сделать невозможными некоторые неправильные вещи.
- Написание кода библиотеки связано с указанием контрактов. Определение интерфейсов для ваших аргументов и типов результатов делает границы ваших контрактов более четко определенными. Если что-то принимает или создает кортеж, нельзя сказать, является ли это тип кортежа, который вы должны фактически получить или создать, и существует очень мало ограничений на такой универсальный тип (имеет ли оно даже правильное количество элементов? они того типа, что вы ожидали?).
"Структурировать" классы с полями
Как уже упоминалось в других ответах, вы можете просто использовать класс с открытыми полями. Если вы сделаете их окончательными, вы получите неизменный класс и инициализируете их с помощью конструктора:
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;
}
}
Конечно, это означает, что вы привязаны к определенному классу, и все, что когда-либо нужно для получения или использования результата анализа, должно использовать этот класс. Для некоторых приложений это нормально. Для других это может вызвать некоторую боль. Большая часть Java-кода посвящена определению контрактов, что обычно приводит вас к интерфейсам.
Еще одна ловушка заключается в том, что при подходе на основе классов вы открываете поля, и все эти поля должны иметь значения. Например, isSeconds и millis всегда должны иметь какое-то значение, даже если isLessThanOneMilli имеет значение true. Какой должна быть интерпретация значения поля миллис, если isLessThanOneMilli имеет значение true?
«Структуры» как интерфейсы
С помощью статических методов, разрешенных в интерфейсах, на самом деле относительно легко создавать неизменяемые типы без большого количества синтаксических издержек. Например, я мог бы реализовать такую структуру результатов, о которой вы говорите, примерно так:
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;
}
};
}
}
Это все еще много шаблонного, я абсолютно согласен, но есть и несколько преимуществ, и я думаю, что они начинают отвечать на некоторые из ваших основных вопросов.
С такой структурой, как этот результат анализа, контракт вашего синтаксического анализатора очень четко определен. В Python один кортеж на самом деле не отличается от другого кортежа. В Java доступна статическая типизация, поэтому мы уже исключаем определенные классы ошибок. Например, если вы возвращаете кортеж в Python и хотите вернуть кортеж (millis, isSeconds, isLessThanOneMilli), вы можете случайно сделать:
return (true, 500, false)
когда вы имели в виду:
return (500, true, false)
С таким интерфейсом Java вы не можете скомпилировать:
return ParseResult.from(true, 500, false);
вообще. Ты должен сделать:
return ParseResult.from(500, true, false);
Это преимущество статически типизированных языков в целом.
Этот подход также дает вам возможность ограничить, какие ценности вы можете получить. Например, при вызове getMillis () вы можете проверить, является ли isLessThanOneMilli () истиной, и, если это так, сгенерировать исключение IllegalStateException (например), поскольку в этом случае нет значащего значения millis.
Трудно сделать неправильную вещь
В приведенном выше примере интерфейса у вас все еще есть проблема, заключающаяся в том, что вы можете случайно поменять местами аргументы isSeconds и isLessThanOneMilli, поскольку они имеют одинаковый тип.
На практике вы действительно можете использовать TimeUnit и длительность, чтобы у вас был такой результат:
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();
}
};
}
}
Это будет намного больше кода, но вам нужно написать его только один раз, и (при условии, что вы правильно документировали вещи), люди, которые в конечном итоге используют ваш код, не должны догадываться, что означает результат, и не может случайно сделать такие вещи, как result[0]
когда они имеют в виду result[1]
. Вы по-прежнему можете довольно кратко создавать экземпляры, и извлекать из них данные не так уж и сложно:
ParseResult2 x = ParseResult2.from(TimeUnit.MILLISECONDS, 32);
ParseResult2 y = ParseResult2.lessThanOneMilli();
Обратите внимание, что вы могли бы сделать что-то подобное и с подходом на основе классов. Просто укажите конструкторы для разных случаев. Тем не менее, у вас все еще есть вопрос, для чего инициализировать другие поля, и вы не можете запретить доступ к ним.
В другом ответе говорилось, что корпоративная природа Java означает, что большую часть времени вы создаете другие библиотеки, которые уже существуют, или пишете библиотеки для использования другими людьми. Ваш общедоступный API не должен требовать много времени для просмотра документации, чтобы расшифровать типы результатов, если этого можно избежать.
Вы пишете эти структуры только один раз, но создаете их много раз, так что вы все еще хотите это краткое создание (которое вы получаете). Статическая типизация гарантирует, что данные, которые вы получаете от них, соответствуют вашим ожиданиям.
Теперь, несмотря на это, есть места, где простые кортежи или списки могут иметь большой смысл. При возврате массива чего-либо может быть меньше накладных расходов, и если это так (и это накладные расходы значительны, что вы определите с помощью профилирования), то использование простого массива значений внутри может иметь большой смысл. Ваш публичный API, вероятно, все еще должен иметь четко определенные типы.