Статьи

Оскорбительное программирование

Как сделать ваш код более лаконичным и в то же время вести себя хорошо

У вас когда-нибудь было приложение, которое просто вело себя странно? Вы знаете, вы нажимаете кнопку, и ничего не происходит. Или экран внезапно становится пустым. Или приложение попадает в «странное состояние», и вам нужно перезапустить его, чтобы все снова заработало.

Если вы испытали это, вы, вероятно, стали жертвой определенной формы защитного программирования, которую я хотел бы назвать «параноидальным программированием». Оборонительный человек охраняется и рассуждает. Параноидальный человек боится и действует странным образом. В этой статье я предложу альтернативный подход: «оскорбительное» программирование.

Осторожный читатель

Как может выглядеть такое параноидальное программирование? Вот типичный пример на Java:

public String badlyImplementedGetData(String urlAsString) {
	// Convert the string URL into a real URL
	URL url = null;
	try {
		url = new URL(urlAsString);
	} catch (MalformedURLException e) {
		logger.error("Malformed URL", e);
	}
 
	// Open the connection to the server
	HttpURLConnection connection = null;
	try {
		connection = (HttpURLConnection) url.openConnection();
	} catch (IOException e) {
		logger.error("Could not connect to " + url, e);
	}
 
	// Read the data from the connection
	StringBuilder builder = new StringBuilder();
	try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
		String line;
		while ((line = reader.readLine()) != null) {
			builder.append(line);
		}
	} catch (Exception e) {
		logger.error("Failed to read data from " + url, e);
	}
	return builder.toString();
}

Этот код просто читает содержимое URL в виде строки. Удивительный объем кода для выполнения очень простой задачи, но такова Java.

Что не так с этим кодом? Кажется, что код обрабатывает все возможные ошибки, но это происходит ужасным образом: он просто игнорирует их и продолжает. Эта практика явно поощряется проверенными примерами Java (глубоко неудачное изобретение), но другие языки видят подобное поведение.

Что произойдет, если что-то пойдет не так:

  • Если URL , который проходил в это неверный URL (например , «HTTP // ..» вместо «HTTP: // …»), следующие прогонов строки в NullPointerException: connection = (HttpURLConnection) url.openConnection();. В данный момент плохой разработчик, который получает отчет об ошибке, потерял весь контекст исходной ошибки, и мы даже не знаем, какой URL вызвал проблему.
  • Если рассматриваемый веб-сайт не существует, ситуация намного, намного хуже: метод вернет пустую строку. Зачем? Результат StringBuilder builder = new StringBuilder();все равно будет возвращен из метода.

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

Давайте посмотрим на код, переписанный оскорбительным образом:

public String getData(String url) throws IOException {
	HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
 
	// Read the data from the connection
	StringBuilder builder = new StringBuilder();
	try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
		String line;
		while ((line = reader.readLine()) != null) {
			builder.append(line);
		}
	}
	return builder.toString();
}

throws IOExceptionЗаявление (необходимо в Java, но никакой другой язык , который я знаю) указывает на то, что этот метод может дать сбой и что метод вызова должен быть готов справиться с этим.

Этот код является более кратким, и в случае ошибки пользователь и журнал получат (предположительно) правильное сообщение об ошибке.

Урок № 1: Не обрабатывать исключения локально.

Защитная нить

Так как же следует обрабатывать ошибки такого рода? Чтобы хорошо обрабатывать ошибки, мы должны рассмотреть всю архитектуру нашего приложения. Допустим, у нас есть приложение, которое периодически обновляет пользовательский интерфейс содержимым некоторого URL.

public static void startTimer() {
	Timer timer = new Timer();
	timer.scheduleAtFixedRate(timerTask(SERVER_URL), 0, 1000);
}
 
private static TimerTask timerTask(final String url) {
	return new TimerTask() {
		@Override
		public void run() {
			try {
				String data = getData(url);
				updateUi(data);
			} catch (Exception e) {
				logger.error("Failed to execute task", e);
			}
		}
	};
}

Это тот тип мышления, который мы хотим! Большинство непредвиденных ошибок невозможно исправить, но мы не хотим, чтобы наш таймер останавливался, не так ли?

Что будет, если это произойдет?

Во-первых, обычной практикой является завершение проверенных исключений Java в RuntimeExceptions:

public static String getData(String urlAsString) {
	try {
		URL url = new URL(urlAsString);
		HttpURLConnection connection = (HttpURLConnection) url.openConnection();
 
		// Read the data from the connection
		StringBuilder builder = new StringBuilder();
		try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
			String line;
			while ((line = reader.readLine()) != null) {
				builder.append(line);
			}
		}
		return builder.toString();
	} catch (IOException e) {
		throw new RuntimeException(e.getMessage(), e);
	}
}

На самом деле, целые библиотеки были написаны с немного большей ценностью, чем скрытие этой уродливой особенности языка Java.

Теперь мы можем упростить наш таймер:

public static void startTimer() {
	Timer timer = new Timer();
	timer.scheduleAtFixedRate(timerTask(SERVER_URL), 0, 1000);
}
 
private static TimerTask timerTask(final String url) {
	return new TimerTask() {
		@Override
		public void run() {
			updateUi(getData(url));
		}
	};
}

Если мы запустим этот код с ошибочным URL-адресом (или сервер не работает), все пойдет не так: мы получаем сообщение об ошибке со стандартной ошибкой, и наш таймер умирает.

На данный момент, одна вещь должна быть очевидна: этот код повторяет, есть ли ошибка, которая вызывает исключение NullPointerException, или сервер в данный момент не работает.

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

public static void startTimer() // ...
 
public static String getData(String urlAsString) // ...
 
private static TimerTask timerTask(final String url) {
	return new TimerTask() {
		@Override
		public void run() {
			try {
				String data = getData(url);
				updateUi(data);
			} catch (IOException e) {
				logger.error("Failed to execute task", e);
			}
		}
	};
}

Урок № 2. Восстановление — не всегда хорошая вещь: вы должны учитывать ошибки, вызванные окружающей средой, такие как проблемы в сети, и какие проблемы вызваны ошибками, которые не исчезнут, пока кто-то не обновит программу.

Вы действительно там?

Допустим, у нас WorkOrdersесть задачи по ним. Каждое задание выполняет какой-то человек. Мы хотим собрать людей, которые участвуют в WorkOrder. Вы, возможно, сталкивались с таким кодом:

public static Set findWorkers(WorkOrder workOrder) {
	Set people = new HashSet();
 
	Jobs jobs = workOrder.getJobs();
	if (jobs != null) {
		List jobList = jobs.getJobs();
		if (jobList != null) {
			for (Job job : jobList) {
				Contact contact = job.getContact();
				if (contact != null) {
					Email email = contact.getEmail();
					if (email != null) {
						people.add(email.getText());
					}
				}
			}
		}
	}
	return people;
}

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

Давайте очистим это:

public static Set findWorkers(WorkOrder workOrder) {
	Set people = new HashSet();
	for (Job job : workOrder.getJobs().getJobs()) {
		people.add(job.getContact().getEmail().getText());
	}
	return people;
}

Вау! Куда делся весь код? Внезапно легко обдумать и снова понять код. Если есть проблема со структурой обрабатываемого нами рабочего заказа, наш код даст нам хороший сбой, чтобы сообщить нам!

Нулевая проверка — один из самых коварных источников параноидального программирования, и они очень быстро размножаются. Изображение, которое вы получили в отчете об ошибке — код только что потерпел крах с NullPointerException (NullReferenceException для вас на C #), в этом коде:

public String getCustomerName() {
	return customer.getName();
}

Люди в стрессе! Чем ты занимаешься? Конечно, вы добавляете еще одну нулевую проверку:

public String getCustomerName() {
        if (customer == null) return null;
	return customer.getName();
}

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

public String getOrderDescription() {
   return getOrderDate() + " " + getCustomerName().substring(0,10) + "...";
}

И так начинается, распространение нулевых проверок через код. Просто пресечь проблему в начале и покончить с этим: не принимать нули.

Кстати, если вам интересно, могли бы мы сделать так, чтобы код синтаксического анализа принимал пустые ссылки и все еще был простым, мы можем это сделать. Допустим, пример с рабочим заданием взят из файла XML. В этом случае мой любимый способ решения проблемы будет примерно таким:

public static Set findWorkers(XmlElement workOrder) {
	Set people = new HashSet();
	for (XmlElement email : workOrder.findRequiredChildren("jobs", "job", "contact", "email")) {
		people.add(email.text());
	}
	return people;
}

Конечно, для этого требуется более приличная библиотека, чем та, которой Java наделена до сих пор.

Урок № 3. Нулевые проверки скрывают ошибки и порождают больше нулевых проверок.

Вывод

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

Сокрытие ошибок позволяет ошибкам размножаться. Взрыв приложения на вашем лице заставляет вас решить реальную проблему.