Pull to refresh
77.63
Wrike
Мы делаем совместную работу проще

Как сделать Java код проще и нагляднее

Reading time 7 min
Views 22K
Вы все пишите блистательно,
а я потом добавлю шероховатости.

х/ф Трамбо

Написать Java код не просто, а очень просто. Трудности начинаются, когда его запускают или, хуже того, если его требуется изменить. Открыв свой код двухлетней давности, каждый хотя бы раз задавался вопросом: кто же все это написал? Мы в Wrike разрабатываем продукт и делаем это уже более десяти лет. Подобные ситуации случались с нами неоднократно. За это время мы выработали ряд принципов в написании кода, которые помогают нам сделать его проще и нагляднее. Хотя в нашем подходе нет ничего экстраординарного, он во многом отличается от того, как принято писать код на Java. Тем не менее, кто-то может найти нечто полезное в нашем подходе и для себя.




Как использовать неизменяемые модели данных


Практика показывает, что код становится существенно проще, если для каждого метода как его параметры, так и результат являются неизменяемыми.

ImmutableList<Double> multiplyValuesByFactor(
                             final ImmutableList<Double> valueList, 
                             final double factor) { … }

Более того, практически всегда можно добиться того же для всех локальные значений методов или полей классов. Логично, при этом, что у класса могут быть только getters. Как быть, если нужно сконструировать сложную модель данных: создавать билдер имеющий только setters и внутри него уже конструировать неизменяемый объект методом build().

Такой подход действительно приводит к более простому и читаемому коду, но чтобы увидеть это, нужно попробовать. Вот несколько примеров в поддержку работы с неизменяемым состоянием. Язык Rust по умолчанию определяет все “переменные” неизменяемыми:

let x = 5;

а для того чтобы получить именно переменную, нужно приложить дополнительные усилия:
let mut y = 6; y=7;

В языке Erlang вообще нет переменных, абсолютно все значения являются неизменяемыми, тем не менее на нем можно разрабатывать сложные приложения. У Robert C. Martin есть интересное выступление на эту тему с хорошо изложенной теорией и не столь хорошими примерами, но теория все равно стоит того, чтобы посмотреть.

It’s a Trap! Как избежать ручного управления ресурсами


Открыв файл, его нужно закрыть. Получив соединение с базой, его нужно освободить. В целом ничего хитрого. Но практика показывает, что при работе с ресурсами Java-разработчики склонны допускать ошибки, вероятно, потому что мы лишены радости управлять памятью вручную. Навык не вырабатывается. Простой пример:

try(final ZipInputStream zipInputStream = 
                 new ZipInputStream(
                          new FileInputStream(file), charset)) {...}

В коде выше есть неочевидная ошибка, из за которой FileInputStream может быть не закрыт.

Простое и дешевое решение — лишить программиста необходимости управлять ресурсами вообще. Например так:

void processFile(final File file, FileProcessor fileProcessor) throws IOException {
	try (final FileInputStream fileInputStream =  new FileInputStream(file)) {
		fileProcessor.processFile(fileInputstream);
	}
}

В этом случае, даже если где-то в логике обработки файла закрадется ошибка, по крайней мере все ресурсы будут корректно освобождены и закрыты.

Как вернуть два значения


Особенно это заметно на интервью, когда начинающие программисты впадают в ступор, если от них требуется вернуть из метода сразу два значения, скажем, сумму и среднее. Решения варьируются по степени своего неприличия от

return new Pair<>(sum, avg)

или

return new AbstractMap.SimpleEntry<>(sum, avg)

до

return new Object[]{sum, avg}

При этом аккуратное решение совсем простое:

public class MathExt {
	public static class SumAndAverage {
		private final long sum;
		private final double average;
	
		public SumAndAverage(final long sum, final double average) {...}

		public long getSum() {...}
		public double getAverage() {...}
	}

	public static SumAndAverage computeSumAndAverage(final ImmutableList<Integer> valueList) {
		...
	} 
}

Совершенно нормально, и более того, удобно, когда вспомогательные модели данных определены рядом с методами, которые их используют. Так принято поступать абсолютно во всех языках программирования, даже в C#, который по большому счету является клоном Java. Однако среди Java программистов можно столкнуться со странным заблуждением, что, мол, “каждый класс должен быть определен в отдельном файле, иначе это плохой код, и точка”. В Java действительно было нельзя создавать inner классы, в версии до 1.1, но с 19 Февраля 1997 года эта проблема была решена, и ничто не мешает нам пользоваться этой новой возможностью языка, которая доступна уже как двадцать лет.

Как спрятать реализацию


Бывает, что в процессе реализации метода возникает необходимость разбить его на несколько более простых методов. При этом неизбежно возникает вопрос: где эти вспомогательные методы определить? Конечно, можно нарезать логику на приватные методы, но если класс достаточно большой, это становится неудобно. Приватные методы неизбежно получаются разбросанными по всему файлу, и какой из них для чего нужен и где используется становится не так очевидно, как хотелось бы. Простое решение, которое помогает как аккуратно организовать код, так и меньше скролить файл при его чтении, — определить вспомогательные методы внутри основного публичного метода.

Для примера, ниже приведен код обхода графа в глубину, при этом для каждого узла графа вызывается callback функция, естественно ровно один раз для каждого узла.

public static void traverseGraphInDepth(
			final Graph graph, 
			final Consumer<Node> callback
) {
	new Runnable() {
		private final HashSet<Node> passedNodeSet = new HashSet<>();		

		public void run() {
			for(final Node startNode:graph.listStartNodes()){
				stepToNode(startNode);
			}	
		}

		void stepToNode(final Node node) {
			if (passedNodeSet.contains(node)) {
				return;
			} else {
				passedNodeSet.add(node);
				callback.accept(node);
				for(final Node nextNode:graph.listNextNodes(node)){
					stepToNode(nextNode);
				}
			}
		}
	}.run(); 
}

Можно было бы определить рядом вспомогательный метод:

private static void stepToNodeImpl(
	final Graph graph, 
	final Node node, 
	final Consumer<Node> callback, 
	final HashSet<Node> passedNodeSetMutable) {...}

Но такое решение получается достаточно громоздким, а сам вспомогательный метод — одиноким. Например, глядя только на название метода stepToNodeImpl неочевидно, что он на самом деле используется для обхода графа в глубину. Чтобы разобраться в этом, нужно сначала найти все его использования.

Тут может быть разумная критика, что много логики будет спрятано внутри метода и отдельно ее невозможно будет тестировать, справедливо. Вопрос, действительно ли все детали реализации, включая приватные методы, покрываются отдельными тестами? Если да, вероятно, логику придется декомпозировать в угоду тестам. Если нет, уж лучше организовать код более аккуратным способом.

На практике, в зависимости от ситуации, бывает удобно реализовывать внутреннюю логику метода используя Runnable, Callable, Consumer или Supplier интерфейсы.

Как вычислить значение


Вычисление отдельного значение может оказаться нетривиальной задачей, если оно вычисляется по сложным правилам или требует дополнительных промежуточных вычислений. В результате код может получиться “замусоренным”. Скажем, дальнейшая логика метода зависит от того, поддерживается ли версия браузера клиента или нет. Для этого нам вначале нужно будет определить тип браузера, затем, в зависимости от типа сравнить версии, и, возможно, учесть какие-то дополнительные параметры.

final boolean isSupportedBrowser = ((Supplier<Boolean>)()->{ 
		final BrowserType browserType = … 
		final boolean isMobileBrowser = … 
		final boolean isTabletBrowser = … 
		final … 
		final …

		if ( … && … ) {
			return true;
		}

		if ( … || … ) {
			return true;
		} else {
			if ( … ) {
				return true;
			… 

		return false; 
}).get();

Примечание: конечно, в данном примере подразумевается, что логика вычисления поддерживаемого клиента сугубо специфична, в противном случае ее само собой нужно вынести в отдельный вспомогательный метод.

Можно было бы вычислить значение isSupportedBrowser непосредственно в коде, но тогда в области видимости остались бы все вспомогательные переменные, а момент, где начинаются вычисления и где они завершаются, был бы не так очевиден. В примере выше конструкция:

isSupportedBrowser = ((Supplier<boolean>)()->{ … }).get();
помогает явно отделить часть логики, отвечающей за вычисление флага, и скрыть все промежуточные значения, которые были необходимы в процессе, но далее не нужны.

Как структурировать скучный код


Код не всегда бывает сложным, иногда его просто много. Задача переложить одну модель данных в другую скорее типична. Ну и что, что в итоге метод занимает пятьсот строк. Все же понятно. Берем это, кладем сюда, берем то, кладем туда и так далее. Встречаются редкие if или дополнительные вычисления, но погоды они не делают. При этом, если попытаться разбить код на более мелкие методы, может получиться даже хуже. Появляется необходимость передавать между между методами данные, определять дополнительные модели для промежуточных результатов методов и так далее.

Для организации такого кода могут быть крайне полезны пустые строки. Разбив код на параграфы, его можно сделать гораздо более читаемым. Но можно пойти дальше, и выделять осмысленные моменты в {} — фигурные скобки. Это не только поможет структурировать код блоками, но и спрятать из области видимости значения необходимые только для одной из секций.

OutputReport transformReport(final InputReport input) {
	final OutputReport output = new OutputReport();
	
	{ //transform report header:
		final boolean someFlagUsedOnlyHere = … 
		… 50 lines of code … 
	}

	{ //transform report body:
		… 50 lines of code … 

		{ //transform section 01:
			… 50 lines of code … 
		}

		… 
		
		{ //transform section 04 (end section):
			… 50 lines of code … 
		}
	}

	{ //transform report summary:
		… 50 lines of code … 
	}

	return output.
} 

Конечно, это не всегда лучший подход. Бывает что сколько {} скобок ни добавляй, код лучше не становится, и его просто нужно переписывать. Бывает, что все делится на методы вполне просто и замечательно. Тем не менее, использование {} позволяет добавить выразительности коду там, где это необходимо.

Заключение


Любое приложение, каким бы большим или маленьким оно ни было, в конечном счете состоит из простых вещей: условий, циклов, выражений, моделей данных, в конечном счете строк кода. Как бы красиво ни называлась архитектура приложения, какие бы паттерны программирования и фреймворки в нем ни использовались, главное — то, насколько просто или сложно понять, как работает код, который перед тобой на экране. Приведенные выше примеры позволяют сделать код чуть менее сложным и чуть более выразительным. По крайней мере, для нас они полезны.
Tags:
Hubs:
+4
Comments 38
Comments Comments 38

Articles

Information

Website
www.wrike.com
Registered
Founded
2006
Employees
1,001–5,000 employees
Location
США
Representative
Wriketeam