Статически типизированные языки могут быть громоздкими для небольших программ, но компилятор таких языков может творить чудеса при работе с большими кодовыми базами. Средство проверки типов для языка со статической типизацией не позволяет передавать неверные аргументы методу. Поэтому ошибки времени выполнения, такие как NoMethodError в Ruby и AttributeError в Python, никогда не проявятся в ваших программах, поскольку вы не можете скомпилировать программу с ошибками типа на статических языках. Нас поощряют рассуждать о наших абстракциях и интерфейсах, поскольку у нас нет «утиной печати», и все же нам нужен гибкий код, который можно легко расширять или изменять.

Тем не менее, многие программисты (включая меня всего несколько недель назад) не слишком задумываются над тем, насколько мощным может быть компилятор как союзник программиста. Как следствие, мы пренебрегаем бесценной поддержкой, которую он предоставляет, и придерживаемся определенных методов, которые могут «выключить» компилятор и привести к ошибкам. Что значит «выключить компилятор»? Это означает, что нельзя полагаться на способность компилятора обнаруживать ошибки во время компиляции и позволять этим ошибкам заразить и воспроизвести в нашем коде без нашего ведома. Мы воспроизводим поведение скриптового языка (обнаружение ошибок только во время выполнения), но без всех преимуществ скриптового языка. Программа получает худшее из обоих миров.

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

Первое, о чем я хотел бы поговорить:

1 - Избавление от всех нулевых ссылок

Если вы хотите создать более надежный Java / C # / C ++ / etc. программы, пожалуйста, прекратите хранить ссылки null в переменных или полях объектов, передавая ссылки null в качестве параметров метода, и не позволяйте методам возвращать ссылки null . Серьезно, во время выполнения в вашей программе не должно быть ни одного null.

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

  • При представлении необязательных атрибутов объекта. Например, узел дерева может иметь атрибут «родительский» для представления своего родителя, но корневой узел вообще не имеет родителя;
  • Некоторые методы могут возвращать null вместо правильного объекта, когда во время его выполнения происходит ошибка (возможные причины: 1) он получил неверные параметры; 2) не читает что-то с диска; 3) не удается разобрать некоторые XML и т. Д.);
  • При доступе к несуществующим ключам в HashMaps с get вместо генерирования исключения возвращаются пустые ссылки;
  • При неполной инициализации объекта при его создании. Мы ожидаем, что вызывающий вызовет object.setSomething(x) для передачи недостающей части информации, необходимой объекту для работы.

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

(Somewhere.java)
Something obj = getSomething(); // this may fail and return a null
SomethingElse selse = new SomethingElse(obj);
...
(SomewhereElse.java)
SomethingElse selse = getSomethingElse();
selse.getSomething().doSomethingUseful()¹; // BANG!

В некотором классе Java (Somewhere.java) мы вызываем операцию для создания объекта и передачи его ссылки другому объекту. Если операция getSomething() завершается неудачно и мы позволяем ей возвращать ссылку null, это создает проблему. Если когда-нибудь позже, в другом классе или даже в другом потоке, мы воспользуемся атрибутом этого объекта для выполнения чего-то полезного, перед нами возникнет отвратительное NullPointerException. Что происходит дальше: нам нужно чертовски отладить нашу программу и вернуться к Somewhere.java, чтобы обнаружить, что мы не смогли предоставить правильный Something объект нашему SomethingElse объекту.

Компилятор не может заставить нас выполнять if(obj != null){ ... тест каждый раз, когда мы касаемся ссылки. Если мы позволим ссылкам null распространяться в нашей программе, она будет такой же свободной, как и любая программа на языке сценариев, со многими потенциальными ошибками, которые могут проявиться во время выполнения.

Я повторю: не позволяйте нулевым ссылкам храниться как переменные / поля, не позволяйте методам возвращать null, когда они что-то не делают, не передавайте пустые ссылки в качестве параметров и будьте осторожны с Hashmap.get (key), поскольку он может вернуть ссылку null в случае отсутствия ключа.

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

1.1 - Использование шаблона нулевого объекта.

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

Нулевой объект обычно содержит пустые операции вместо некоторого поведения. Он отличается от нулевой ссылки, поскольку является живым объектом и может отвечать на вызовы методов, не нарушая работу программы во время выполнения. Он следует ожидаемому интерфейсу, и компилятор может вам это гарантировать. Просто так случается, что он не представляет значимый объект и может ничего не делать вместо того, чтобы что-то делать. В качестве примера предположим, что вы создали графический инструментарий, такой как Swing. Предположим также, что вы хотите, чтобы объект Window имел необязательное свойство объекта прослушивателя, чтобы пользователи могли получать уведомления при возникновении событий closed / maximized / восстановлено / свернуто.

Самый наивный подход к реализации этого:

  1. Создание поля WindowListener без его инициализации в конструкторе (позволяя ему иметь значение null);
  2. Предоставление метода setListener, чтобы третьи стороны могли указать настраиваемый прослушиватель;
  3. Использование if(listener != null) каждый раз, когда вы хотите уведомить слушателя, если он существует.

Если вы следуете наивному подходу, не ожидайте, что компилятор предупредит вас, когда вы забудете использовать тест if-not-null каждый раз, когда захотите коснуться ссылки. Компилятор (Java) не может сделать это за вас. Итак, почему бы вместо этого не использовать нулевой объект? Просто инициализируйте поле слушателя нулевым объектом, например:

private WindowListener listener = new WindowListener(){
public void windowOpened(){}
public void windowMaximized(){}
...
}

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

Некоторые заключительные замечания о шаблоне нулевого объекта:

  • Является ли это лучшим методом борьбы с нулевыми ссылками? Абсолютно нет. Иногда вы не можете позволить себе использовать нулевой объект и вообще хотите, чтобы что-то было сделано. Если это так, вам нужно больше обдумать дизайн своей программы. Каждый конкретный случай будет отличаться, и я не могу дать вам окончательный совет, который подойдет для каждой ситуации. Не ожидаемые пустые объекты могут «проглатывать» вызовы методов и вносить небольшие ошибки в вашу программу, например, пустой «улов», поглощающий исключения, которые должны обрабатываться программой;
  • А как насчет производительности и использования памяти? Если создание большого количества одного и того же нулевого объекта является проблемой, просто используйте одноэлементный или статический конечный атрибут. Вам также следует подумать об использовании паттерна наилегчайшего веса в некоторых ситуациях.

1.2 Использование Map.getOrElse (ключ, значение по умолчанию) вместо Map.get

Карты - это очень полезная структура данных, но, к сожалению, разработчики Java решили разрешить возвращение ссылок null, когда get вызывается с несуществующим ключом в качестве параметра. Я бы позволил ему генерировать RuntimeException -based вместо возврата null ссылки, чтобы обеспечить отказоустойчивый подход, но я не главный архитектор Java. Кстати, это то, что делает Python. Доступ к несуществующему ключу в Python вызывает ошибку KeyError.

Как мы можем использовать эту красивую структуру данных в Java без опасностей, связанных с нулевыми ссылками?

Что ж, было бы неплохо иметь такой декоратор карты:

public class NullNotAllowedMap implements Map<K,V>{
private Map<K,V> map;
public NullNotAllowedMap(Map<K,V> map){
   this.map = map;
}
public NullNotAllowedMap(){
   this.map = new HashMap<>();
}
...
public V get(K key){
   if( ! map.containsKey(key) ){
      throws new KeyNotFoundException(key);
   }
   return map.get(key);
}

Если есть что-то подобное в Maven Central, пожалуйста, дайте мне знать.

Альтернативой является использование getOrDefault, который принимает значение по умолчанию в качестве параметра и возвращает его, если ключ не найден. Таким образом вы предотвратите создание нулевой ссылки. Вы можете поспорить со мной, что вы по-прежнему обязаны проверять, равен ли возвращаемый объект значению по умолчанию, и что это будет то же самое, что и при использовании get и if(obj != null) test. Позвольте мне сказать вам кое-что: вы очень ошибаетесь! Почему? Поступая так, вы подтверждаете, что НУЛЕВЫЕ ССЫЛКИ НЕ ДОПУСКАЮТСЯ СУЩЕСТВОВАТЬ В ВАШЕЙ ПРОГРАММЕ, и именно это я пытаюсь убедить вас сделать. Вы также можете комбинировать этот подход с методом шаблона нулевого объекта, и в некоторых случаях проверка не потребуется.

Конечно, есть еще одна разумная альтернатива - всегда звонить containsKey перед звонком get. Компилятор не может обеспечить, мы всегда защищаем get вызовов с помощью containsKey вызова и условного оператора. Однако не забудьте защитить себя от получения значений NULL из объекта Hashmap.

Другими словами: для любого заданного Hashmap get является частичной функцией. Частичные функции не определены для всех возможных значений параметров. Метод get определен не для всех возможных строк, а только для ключей карты. Он похож на оператор деления, который не определяется, когда знаменатель равен нулю. Когда мы пытаемся разделить что-либо на ноль, это нормально. Можно предположить, что программисты перед делением будут проверять, отличен ли знаменатель от нуля. Точно так же можно предположить, что пользователи Hashmap вызовут containsKey, прежде чем что-либо получат. Оператор деления также может выдавать какое-то странное значение, например «БЕСКОНЕЧНОСТЬ» или «НЕ ЧИСЛО». Это так неправильно. Это не четные числа. Позволяя операции деления генерировать значение INFINITY / NaN², в нашей программе можно незаметно вносить ошибки. Состояние нашей программы может стать непоследовательным, и это может быть трудно обнаружить. Разрешение Hashmap.get возвращать пустые ссылки точно так же вредит нашим программам.

1.3 Использование Optional ‹Type› вместо Type, чтобы указать, что вы имеете дело с потенциально отсутствующим атрибутом.

Используя тип Optional, легко понять, что вы имеете дело с потенциально несуществующим объектом. Переписываем наш предыдущий код с помощью:

Optional<Something> opt = getSomething(); // an Optional.none() is created if the method fails to create a meaningful object
SomethingElse selse = new SomethingElse(opt);

Объект Optional<Something> opt может быть либо конвертом с правильным объектом Something, либо пустым конвертом, внутри которого ничего нет. Мы можем проверить, содержит ли конверт объект с opt.isPresent(), и мы можем получить доступ к существующему объекту с opt.get(). Мы все равно можем получить ошибку, если сделаем:

obj.getSomething().get().doSomething()

без предварительной проверки этот конверт содержит что-то внутри с помощью метода isPresent(). Но это гораздо менее вероятно по сравнению с использованием пустых ссылок в качестве правильных объектов. Когда вы обертываете объект с типом Optional, компилятор становится на вашем пути (в положительном смысле) каждый раз, когда вы пытаетесь получить к нему доступ. При доступе к объекту, заключенному в коробку с типом Optional, вы автоматически понимаете, что вы имея дело с потенциально отсутствующим объектом, и, конечно же, вы не забудете использовать тест isPresent. Вы также можете управлять им с помощью метода flatMap, что тоже неплохо.

Что компилятор должен делать с пустыми ссылками, нулевыми объектами, значениями по умолчанию и необязательными типами? Как компилятор может быть нашим союзником в этом смысле?

Позволяя нулевым ссылкам сохраняться и пересылаться в наших программах, мы получаем сломанные абстракции / интерфейсы. Пустая ссылка не подходит для мира ООП. Многие программисты считают, что пустое значение можно использовать для представления отсутствующих или необязательных данных. Однако null - плохая абстракция для представления отсутствующих / необязательных данных. Объекты обрабатываются по их ссылкам (адресам памяти). Действительные ссылки указывают на живой объект, который может отвечать на сообщения. С другой стороны, пустая ссылка - это просто недопустимый адрес, не указывающий ни на один объект. У него нет типа, он не отвечает на сообщения и нарушает работу нашей программы во время выполнения, если мы пытаемся ее использовать.

Пустая ссылка ничего не говорит о том, как нам не удалось предоставить объект. Это была ошибка? Это были неправильные входные данные? Это означает отсутствие информации? У нас нет возможности гадать. Таким образом, использование значений NULL делает наши программы неоднозначными. Мы не можем отличить типы, не допускающие значения NULL (объекты, которые не должны иметь значение NULL в любой ситуации) от типов, допускающих значение NULL (объекты, которые могут отсутствовать для представляют недостающую информацию). Вот почему использование null "снижает" способность компилятора обнаруживать ошибки.

Решение состоит в том, чтобы использовать допустимые типы вместо нулей. Нулевой объект представляет объект с типом. Значение по умолчанию (ненулевое) имеет правильный тип. Необязательный ‹Something› также является допустимым типом: он представляет собой контейнер с нулевым или одним элементом Something.

Предполагая, что все ссылки указывают на правильные объекты, наша жизнь становится проще при разработке / сопровождении объектно-ориентированных программ. Используя специальный тип (например, Optional ‹*›) для представления потенциально отсутствующей информации, мы можем без двусмысленности заявить о своем намерении. Эта практика помогает нам, компилятору и нашим коллегам-программистам работать с одним и тем же кодом.

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

data OptDate = Date Int Int Int | MissingDate 
isMissing :: OptDate -> Bool
isMissing (Date _ _ _) = False
isMissing MissingDate = True

Обработка OptDate экземпляров легко выполняется с помощью возможностей сопоставления с образцом в Haskell. Компилятор заставляет нас иметь дело с возможностью получения MissingDate, и мы справляемся с этим, применяя сопоставление с образцом, как в примере выше. В Haskell нет способа сломать нашу программу, предположив, что какой-то экземпляр MissingDate является (Date Int Int Int), и компилятор гарантирует это для нас. Мы стремимся получить этот уровень безопасности в Java, когда используем тип Optional ‹*› вместо использования null.

Если вы думаете, что тип Optional ‹*› неуклюжий, нетрудно представить тип объединения Something | Nothing по-другому:

interface Nullable {
  public boolean isMissing();
}
interface Date {
  public int day();
  public int month();
  public int year();
}
interface NullableDate extends Date, Nullable {
    public static final NullableDate MISSING = new NullableDate() {
    @Override
    public int day() {throw new UnsupportedOperationException();}
    @Override
    public int month() {throw new UnsupportedOperationException();}
    @Override
    public int year() {throw new UnsupportedOperationException();}
    @Override
    public boolean isMissing() {
      return true;
    }
  };
}
class ConcreteDate implements NullableDate {
  private final int day;
  private final int month;
  private final int year;
  
  public ConcreteDate(int day, int month, int year) {
    this.day = day;
    this.month = month;
    this.year = year;
  }
  
  @Override
  public int day() { return day; }
  @Override
  public int month() { return month;  }
  @Override
  public int year() { return year;  }
  @Override
  public boolean isMissing() {  return false; }
}

Используя правильные типы, мы избегаем любой двусмысленности. Date d Ссылка всегда указывает на действительную дату. Ссылка NullableDate nd может указывать либо на действительную дату, либо представлять отсутствующую дату. Так работает язык Kotlin: Date d ссылка никогда не хранит null (сам компилятор запрещает это), а ссылка aDate? d может содержать null. Следовательно, имея дело с типами без вопросительного знака, мы можем удобно устроиться на стульях и ожидать, что исключение NullPointerException никогда не произойдет.

________________________________________

¹ Некоторые из вас, ребята, могут понять, что этот пример нарушает «закон Деметры», но я не строго придерживаюсь этого «закона».

² Угадайте, КАКОЙ язык дает «Бесконечность», когда вы делите что-то на ноль. Если вы угадали «Javascript», лото, то вы правы !.