Время на прочтение
14 мин
Количество просмотров 106K
Здравствуйте, меня зовут Александр Акбашев, я Lead QA Engineer в проекте Skyforge. А также по совместительству ассистент tully в Технопарке на курсе «Углубленное программирование на Java». Наш курс идет во втором семестре Технопарка, и мы получаем студентов, прошедших курсы по C++ и Python. Поэтому я давно хотел подготовить материал, посвященный самым распространенным ошибкам новичков в Java. К сожалению, написать такую статью я так и не собрался. К счастью, такую статью написал наш соотечественник — Михаил Селиванов, правда, на английском. Ниже представлен перевод данной статьи с небольшими комментариями. По всем замечаниям, связанным с переводом, прошу писать в личные сообщения.
Изначально язык Java создавался для интерактивного телевидения, однако со временем стал использоваться везде, где только можно. Его разработчики руководствовались принципами объектно-ориентированного программирования, отказавшись от излишней сложности, свойственной тем же С и С++. Платформонезависимость виртуальной машины Java сформировала в своё время новый подход к программированию. Добавьте к этому плавную кривую обучения и лозунг «Напиши однажды, запускай везде», что почти всегда соответствует истине. Но всё-таки ошибки до сих пор встречаются, и здесь я хотел бы разобрать наиболее распространённые из них.
Ошибка первая: пренебрегают существующими библиотеками
Для Java написано несметное количество библиотек, но новички зачастую не пользуются всем этим богатством. Прежде чем изобретать велосипед, лучше сначала изучите имеющиеся наработки по интересующему вопросу. Многие библиотеки годами доводились разработчиками до совершенства, и вы можете пользоваться ими совершенно бесплатно. В качестве примеров можно привести библиотеки для логирования logback и Log4j, сетевые библиотеки Netty и Akka. А некоторые разработки, вроде Joda-Time, среди программистов стали стандартом де-факто.
На эту тему хочу рассказать о своём опыте работы над одним из проектов. Та часть кода, которая отвечала за экранирование HTML-символов, была написана мной с нуля. Несколько лет всё работало без сбоев. Но однажды пользовательский запрос спровоцировал бесконечный цикл. Сервис перестал отвечать, и пользователь попытался снова ввести те же данные. В конце концов, все процессорные ресурсы сервера, выделенные для этого приложения, оказались заняты этим бесконечным циклом. И если бы автор этого наивного инструмента для замены символов воспользовался одной из хорошо известных библиотек, HtmlEscapers или Google Guava, вероятно, этого досадного происшествия не произошло. Даже если бы в библиотеке была какая-то скрытая ошибка, то наверняка она была бы обнаружена и исправлена сообществом разработчиков раньше, чем проявилась бы на моём проекте. Это характерно для большинства наиболее популярных библиотек.
Ошибка вторая: не используют ключевое слово break в конструкции Switch-Case
Подобные ошибки могут сильно сбивать с толку. Бывает, что их даже не обнаруживают, и код попадает в продакшен. С одной стороны, неудачное выполнение операторов switch часто бывает полезным. Но если так не было задумано изначально, отсутствие ключевого слова break может привести к катастрофическим результатам. Если в нижеприведённом примере опустить break в case 0, то программа после Zero выведет One, поскольку поток выполнения команд пройдёт через все switch, пока не встретит break.
public static void switchCasePrimer() {
int caseIndex = 0;
switch (caseIndex) {
case 0:
System.out.println("Zero");
case 1:
System.out.println("One");
break;
case 2:
System.out.println("Two");
break;
default:
System.out.println("Default");
}
}
Чаще всего целесообразно использовать полиморфизм для выделения частей кода со специфическим поведением в отдельные классы. А подобные ошибки можно искать с помощью статических анализаторов кода, например, FindBugs или PMD.
Ошибка третья: забывают освобождать ресурсы
Каждый раз после того, как программа открывает файл или устанавливает сетевое соединение, нужно освобождать использовавшиеся ресурсы. То же самое относится и к ситуациям, когда при оперировании ресурсами возникали какие-либо исключения. Кто-то может возразить, что у FileInputStream есть финализатор, вызывающий метод close() для сборки мусора. Но мы не можем знать точно, когда запустится цикл сборки, поэтому есть риск, что входной поток может занять ресурсы на неопределённый период времени. Специально для таких случаев в Java 7 есть очень полезный и аккуратный оператор try-with-resources:
private static void printFileJava7() throws IOException {
try(FileInputStream input = new FileInputStream("file.txt")) {
int data = input.read();
while(data != -1){
System.out.print((char) data);
data = input.read();
}
}
}
Этот оператор можно применять с любыми объектами, относящимися к интерфейсу AutoClosable. Тогда вам не придётся беспокоиться об освобождении ресурсов, это будет происходить автоматически после выполнения оператора.
Ошибка четвёртая: утечки памяти
В Java применяется автоматическое управление памятью, позволяющее не заниматься ручным выделением и освобождением. Но это вовсе не означает, что разработчикам можно вообще не интересоваться, как приложения используют память. Увы, но всё же здесь могут возникать проблемы. До тех пор, пока программа удерживает ссылки на объекты, которые больше не нужны, память не освобождается. Таким образом, это можно назвать утечкой памяти. Причины бывают разные, и наиболее частой из них является как раз наличие большого количества ссылок на объекты. Ведь пока есть ссылка, сборщик мусора не может удалить этот объект из кучи. Например, вы описали класс со статическим полем, содержащим коллекцию объектов, при этом создалась ссылка. Если вы забыли обнулить это поле после того, как коллекция стала не нужна, то и ссылка никуда не делась. Такие статические поля считаются корнями для сборщика мусора и не собираются им.
Другой частой причиной возникновения утечек является наличие циклических ссылок. В этом случае сборщик просто не может решить, нужны ли ещё объекты, перекрёстно ссылающиеся друг на друга. Утечки также могут возникать в стеке при использовании JNI (Java Native Interface). Например:
final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
final Deque<BigDecimal> numbers = new LinkedBlockingDeque<>();
final BigDecimal divisor = new BigDecimal(51);
scheduledExecutorService.scheduleAtFixedRate(() -> {
BigDecimal number = numbers.peekLast();
if (number != null && number.remainder(divisor).byteValue() == 0) {
System.out.println("Number: " + number);
System.out.println("Deque size: " + numbers.size());
}
}, 10, 10, TimeUnit.MILLISECONDS);
scheduledExecutorService.scheduleAtFixedRate(() -> {
numbers.add(new BigDecimal(System.currentTimeMillis()));
}, 10, 10, TimeUnit.MILLISECONDS);
try {
scheduledExecutorService.awaitTermination(1, TimeUnit.DAYS);
} catch (InterruptedException e) {
e.printStackTrace();
}
Здесь создаётся два задания. Одно из них берёт последнее число из двусторонней очереди numbers и выводит его значение и размер очереди, если число кратно 51. Второе задание помещает число в очередь. Для обоих заданий установлено фиксированное расписание, итерации происходят с интервалом в 10 миллисекунд. Если запустить этот код, то размер очереди будет увеличиваться бесконечно. В конце концов это приведёт к тому, что очередь заполнит всю доступную память кучи. Чтобы этого не допустить, но при этом сохранить семантику кода, для извлечения чисел из очереди можно использовать другой метод: pollLast. Он возвращает элемент и удаляет его из очереди, в то время как peekLast только возвращает.
Если хотите узнать побольше об утечках памяти, то можете изучить посвящённую этому статью.
Примечание переводчика: на самом деле, в Java решена проблема циклических ссылок, т.к. современные алгоритмы сборки мусора учитывают достижимость ссылок из корневых узлов. Если объекты, содержащие ссылки друг на друга, не достижимы от корня, они будут считаться мусором. Об алгоритмах работы сборщика мусора можно почитать в Java Platform Performance: Strategies and Tactics.
Ошибка пятая: чрезмерное количество мусора
Такое случается, когда программа создаёт большое количество объектов, использующихся в течение очень непродолжительного времени. При этом сборщик мусора безостановочно убирает из памяти ненужные объекты, что приводит к сильному падению производительности. Простой пример:
String oneMillionHello = "";
for (int i = 0; i < 1000000; i++) {
oneMillionHello = oneMillionHello + "Hello!";
}
System.out.println(oneMillionHello.substring(0, 6));
В Java строковые переменные являются неизменяемыми. Здесь при каждой итерации создаётся новая переменная, и для решения этой проблемы нужно использовать изменяемый StringBuilder:
StringBuilder oneMillionHelloSB = new StringBuilder();
for (int i = 0; i < 1000000; i++) {
oneMillionHelloSB.append("Hello!");
}
System.out.println(oneMillionHelloSB.toString().substring(0, 6));
Если в первом варианте на выполнение кода уходит немало времени, то во втором производительность уже гораздо выше.
Ошибка шестая: использование без необходимости нулевых указателей
Старайтесь избегать применения null. Например, возвращать пустые массивы или коллекции лучше методами, чем null, поскольку это позволит предотвратить появление NullPointerException. Ниже представлен пример метода, обрабатывающего коллекцию, полученную из другого метода:
List<String> accountIds = person.getAccountIds();
for (String accountId : accountIds) {
processAccount(accountId);
}
Если getAccountIds() возвращает null, когда у person нет account, то возникнет NullPointerException. Чтобы этого не произошло, необходимо делать проверку на null. А если вместо null возвращается пустой список, то проблема с NullPointerException не возникает. К тому же код без null-проверок получается чище.
В разных ситуациях можно по-разному избегать использования null. Например, использовать класс Optional, который может быть как пустым объектом, так и обёрткой (wrap) для какого-либо значения:
Optional<String> optionalString = Optional.ofNullable(nullableString);
if(optionalString.isPresent()) {
System.out.println(optionalString.get());
}
В Java 8 используется более лаконичный подход:
Optional<String> optionalString = Optional.ofNullable(nullableString);
optionalString.ifPresent(System.out::println);
Optional появился в восьмой версии Java, но в функциональном программировании он использовался ещё задолго до этого. Например, в Google Guava для ранних версий Java.
Ошибка седьмая: игнорирование исключений
Зачастую начинающие разработчики никак не обрабатывают исключения. Однако не стоит пренебрегать этой работой. Исключения бросаются не просто так, и в большинстве случаев нужно разбираться с причинами. Не игнорируйте подобные события. Если нужно, бросьте их заново, чтобы посмотреть сообщение об ошибке, или залогируйте. В крайнем случае, нужно хотя бы обосновать для других разработчиков причину, по которой вы не разбирались с исключением.
selfie = person.shootASelfie();
try {
selfie.show();
} catch (NullPointerException e) {
// Может, человек-невидимка. Да какая разница?
}
Лучше всего обозначить незначительность исключения с помощью сообщения в переменной:
try { selfie.delete(); } catch (NullPointerException unimportant) { }
Примечание переводчика: Практика не устаёт доказывать, что не бывает неважных исключений. Если исключение хочется проигнорировать, то нужно добавлять какие-то дополнительные проверки, чтобы либо не вызывать исключение в принципе, либо игнорировать исключение сверхточечно. Иначе вас ожидают долгие часы дебага в поисках ошибки, которую так легко было написать в лог. Также нужно помнить, что создание исключения — операция не бесплатная. Как минимум, нужно собрать коллстэк, а для этого нужно приостановиться на safepoint. И это всё занимает время…
Ошибка восьмая: ConcurrentModificationException
Это исключение возникает, когда коллекция модифицируется во время итерирования любыми методами, за исключением средств самого итератора. Например, у нас есть список головных уборов, и мы хотим убрать из него все шапки-ушанки:
List<IHat> hats = new ArrayList<>();
hats.add(new Ushanka()); // that one has ear flaps
hats.add(new Fedora());
hats.add(new Sombrero());
for (IHat hat : hats) {
if (hat.hasEarFlaps()) {
hats.remove(hat);
}
}
При выполнении этого кода вылезет ConcurrentModificationException, поскольку код модифицирует коллекцию во время итерирования. То же самое исключение возникнет, если один из нескольких тредов, работающих с одним списком, попытается модифицировать коллекцию, пока другие треды итерируют её. Одновременное модифицирование коллекции является частым явлением при многопоточности, но в этом случае нужно применять соответствующие инструменты, вроде блокировок синхронизации (synchronization lock), специальных коллекций, адаптированных для одновременной модификации и т.д.
В случае с одним тредом эта проблема решается немного иначе.
Собрать объекты и удалить их в другом цикле
На ум сразу приходит решение собрать ушанки и удалить их во время следующего цикла. Но тогда придётся создать новую коллекцию для хранения шапок, приготовленных для удаления.
List<IHat> hatsToRemove = new LinkedList<>();
for (IHat hat : hats) {
if (hat.hasEarFlaps()) {
hatsToRemove.add(hat);
}
}
for (IHat hat : hatsToRemove) {
hats.remove(hat);
}
Использовать метод Iterator.remove
Это более лаконичный способ, при котором не нужно создавать новую коллекцию:
Iterator<IHat> hatIterator = hats.iterator();
while (hatIterator.hasNext()) {
IHat hat = hatIterator.next();
if (hat.hasEarFlaps()) {
hatIterator.remove();
}
}
Использовать методы ListIterator
Когда модифицированная коллекция реализует интерфейс List, целесообразно использовать итератор списка (list iterator). Итераторы, реализующие интерфейс ListIterator, поддерживают как операции удаления, так и добавления и присвоения. ListIterator реализует интерфейс Iterator, так что наш пример будет выглядеть почти так же, как и метод удаления Iterator. Разница заключается в типе итератора шапок и его получении с помощью метода listIterator(). Нижеприведённый фрагмент демонстрирует, как можно заменить каждую ушанку на сомбреро с помощью методов ListIterator.remove и ListIterator.add:
IHat sombrero = new Sombrero();
ListIterator<IHat> hatIterator = hats.listIterator();
while (hatIterator.hasNext()) {
IHat hat = hatIterator.next();
if (hat.hasEarFlaps()) {
hatIterator.remove();
hatIterator.add(sombrero);
}
}
С помощью ListIterator вызовы методов удаления и добавления могут быть заменены одним вызовом:
IHat sombrero = new Sombrero();
ListIterator<IHat> hatIterator = hats.listIterator();
while (hatIterator.hasNext()) {
IHat hat = hatIterator.next();
if (hat.hasEarFlaps()) {
hatIterator.set(sombrero); // set instead of remove and add
}
}
Используя поточные методы, представленные в Java 8, можно трансформировать коллекцию в поток, а потом отфильтровать его по каким-либо критериям. Вот пример того, как поточный API может помочь в фильтрации шапок без появления ConcurrentModificationException:
hats = hats.stream().filter((hat -> !hat.hasEarFlaps()))
.collect(Collectors.toCollection(ArrayList::new));
Метод Collectors.toCollection создаёт новый ArrayList с отфильтрованными шапками. Если критериям удовлетворяет большое количество объектов, то это может быть проблемой, поскольку ArrayList получается довольно большим. Так что пользуйтесь этим способом с осторожностью.
Можно поступить другим образом — использовать метод List.removeIf, представленный в Java 8. Это самый короткий вариант:
hats.removeIf(IHat::hasEarFlaps);
И всё. На внутреннем уровне этот метод задействуется Iterator.remove.
Использовать специализированные коллекции
Если бы в самом начале мы решили использовать CopyOnWriteArrayList вместо ArrayList, то проблем бы вообще не было, потому что CopyOnWriteArrayList использует методы модифицирования (присвоения, добавления и удаления), которые не меняют базовый массив (backing array) коллекции. Вместо этого создаётся новая, модифицированная версия. Благодаря этому можно одновременно итерировать и модифицировать исходную версию коллекции без опасения получить ConcurrentModificationException. Недостаток у этого способа очевиден — приходится генерировать новую коллекцию для каждой модификации.
Существуют коллекции, настроенные для разных случаев, например, CopyOnWriteSet и ConcurrentHashMap.
Другой возможной ошибкой, связанной с ConcurrentModificationException, является создание потока из коллекции, а потом модифицирование базовой коллекции (backing collection) во время итерирования потока. Избегайте этого. Ниже приведён пример неправильного обращения с потоком:
List<IHat> filteredHats = hats.stream().peek(hat -> {
if (hat.hasEarFlaps()) {
hats.remove(hat);
}
}).collect(Collectors.toCollection(ArrayList::new));
Метод peek собирает все элементы и применяет к каждому определённое действие. В данном случае, пытается удалить элемент из базового списка, что не есть правильно. Старайтесь применять другие методы, описанные выше.
Ошибка девятая: нарушение контрактов
Бывает, что для правильной работы кода из стандартной библиотеки или от какого-то вендора нужно соблюдать определённые правила. Например, контракт hashCode и equals гарантирует работу набора коллекций из фреймворка коллекций Java, а также других классов, использующих методы hashCode и equals. Несоблюдение контракта не всегда приводит к исключениям или прерыванию компиляции. Тут всё несколько сложнее, иногда это может повлиять на работу приложения так, что вы не заметите ничего подозрительного. Ошибочный код может попасть в продакшен и привести к неприятным последствиям. Например, стать причиной глючности UI, неправильных отчётов данных, низкой производительности, потери данных и т.д. К счастью, такое случается редко. Тот же вышеупомянутый контракт hashCode и equals используется в коллекциях, основанных на хэшинге и сравнении объектов, вроде HashMap и HashSet. Проще говоря, контракт содержит два условия:
- Если два объекта эквивалентны, то их коды тоже должны быть эквивалентны.
- Если даже два объекта имеют одинаковые хэш-коды, то они могут и не быть эквивалентны.
Нарушение первого правила приводит к проблемам при попытке извлечения объектов из hashmap.
public static class Boat {
private String name;
Boat(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Boat boat = (Boat) o;
return !(name != null ? !name.equals(boat.name) : boat.name != null);
}
@Override
public int hashCode() {
return (int) (Math.random() * 5000);
}
}
Как видите, класс Boat содержит переопределённые методы hashCode и equals. Но контракт всё равно был нарушен, потому что hashCode возвращает каждый раз случайные значения для одного и того же объекта. Скорее всего, лодка под названием Enterprise так и не будет найдена в массиве хэшей, несмотря на то, что она была ранее добавлена:
public static void main(String[] args) {
Set<Boat> boats = new HashSet<>();
boats.add(new Boat("Enterprise"));
System.out.printf("We have a boat named 'Enterprise' : %b\n", boats.contains(new Boat("Enterprise")));
}
Другой пример относится к методу finalize. Вот что говорится о его функционале в официальной документации Java:
Основной контракт finalize заключается в том, что он вызывается тогда и если, когда виртуальная машина определяет, что больше нет никаких причин, по которым данный объект должен быть доступен какому-либо треду (ещё не умершему). Исключением может быть результат завершения какого-то другого объекта или класса, который готов быть завершённым. Метод finalize может осуществлять любое действие, в том числе снова делать объект доступным для других тредов. Но обычно finalize используется для действий по очистке до того, как объект необратимо удаляется. Например, этот метод для объекта, представляющего собой соединение input/output, может явным образом осуществить I/O транзакции для разрыва соединения до того, как объект будет необратимо удалён.
Не надо использовать метод finalize для освобождения ресурсов наподобие обработчиков файлов, потому что неизвестно, когда он может быть вызван. Это может произойти во время работы сборщика мусора. В результате продолжительность его работы непредсказуемо затянется.
Ошибка десятая: использование сырых типов (raw type) вместо параметризованных
Согласно спецификации Java, сырой тип является либо непараметризованным, либо нестатическим членом класса R, который не унаследован от суперкласса или суперинтерфейса R. До появления в Java обобщённых типов, альтернатив сырым типам не существовало. Обобщённое программирование стало поддерживаться с версии 1.5, и это стало очень важным шагом в развитии языка. Однако ради совместимости не удалось избавиться от такого недостатка, как потенциальная возможность нарушения системы классов.
List listOfNumbers = new ArrayList();
listOfNumbers.add(10);
listOfNumbers.add("Twenty");
listOfNumbers.forEach(n -> System.out.println((int) n * 2));
Здесь список номеров представлен в виде сырого ArrayList. Поскольку его тип не задан, мы можем добавить в него любой объект. Но в последней строке в int забрасываются элементы, удваиваются и выводятся. Этот код откомпилируется без ошибок, но если его запустить, то выскочит исключение на этапе выполнения (runtime exception), потому что мы пытаемся записать строчную переменную в числовую. Очевидно, что если мы скроем от системы типов необходимую информацию, то она не убережёт нас от написания ошибочного кода. Поэтому старайтесь определять типы объектов, которые собираетесь хранить в коллекции:
List<Integer> listOfNumbers = new ArrayList<>();
listOfNumbers.add(10);
listOfNumbers.add("Twenty");
listOfNumbers.forEach(n -> System.out.println((int) n * 2));
От первоначального варианта этот пример отличается строкой, в которой задаётся коллекция:
List<Integer> listOfNumbers = new ArrayList<>();
Этот вариант не откомпилируется, поскольку мы пытаемся добавить строковую переменную в коллекцию, которая может хранить только числовые. Компилятор выдаст ошибку и укажет на строку, в которой мы пытаемся добавить в список строковую Twenty. Так что всегда старайтесь параметризировать обобщённые типы. В этом случае компилятор сможет всё проверить, и шансы появления runtime exception из-за противоречий в системе типов будут сведены к минимуму.
Заключение
Многие моменты в разработке ПО на платформе Java упрощены, благодаря разделению на сложную Java Virtual Machine и сам язык. Однако широкие возможности, вроде автоматического управления памятью или приличных OOP-инструментов, не исключают вероятности возникновения проблем. Советы здесь универсальны: регулярно практикуйтесь, изучайте библиотеки, читайте документацию. И не забывайте о статических анализаторах кода, они могут указать на имеющиеся баги и подсказать, на что стоит обратить внимание.
Какие ошибки чаще других встречаются у новичков в программировании? Возможно, они всегда путают равенство (==
) с присвоением (=
), или &
с &&
? Или же, возможно, они используют неправильные разделители в цикле for (for (int i = 0, i < 5, i++)
)?
Чтобы ответить на этот вопрос, исследователи рассмотрели ошибки более чем 250 000 Java-новичков со всего мира. Используя большое количество данных (исходный код более чем 37 миллионов компиляций, если быть точнее), они выявили наиболее распространенные ошибки студентов, впервые изучавших Java. Также они выяснили, как много времени занимала учёба на собственных ошибках. Результаты оказались поразительными.
На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.
The Blackbox data collection project
Изучение нового языка программирования — это всегда вызов, поскольку вы должны научиться выражать свои сложные мысли, используя ограниченную, формальную грамматику. Естественно, многие новички делают ошибки при программировании. Чтобы пролить немного света на то, какие ошибки чаще всего делают новички, исследователи из университета Кента (Великобритания) рассмотрели код, который был собран почти у 265 000 студентов по всему миру.
Проект под названием Blackbox data collection построен на основе BlueJ, бесплатной Java IDE, спроектированной специально для новичков. BlueJ пытается устранить барьеры на пути к успеху для новичков, например, выделяя начало и конец блока кода. Это облегчает студентам процесс визуального сканирования кода на наличие лишних фигурных скобок. Ещё одна особенность данной IDE — это возможность проверки объектов прямо во время работы программы. Таким образом, это помогает пониманию принципов работы программы, а также отладке.
Blackbox также работает как расширение для BlueJ, которое собирает различную анонимную информацию о том, как используется программное обеспечение. К примеру, оно собирает информацию о том, какие функции выполняются и когда, а также с какими ошибками и как часто. Количество данных огромно. Только за один учебный год (2013—2014) было обработано 37 158 094 компиляций, 19 476 087 из которых были успешными, остальные же 17 682 007 — неудачными.
В то время как большинство людей и помыслить не может об обработке такого большого количества данных, Амджад АльТадмри (Amjad Altadmri) и Нейл Браун (Neil Brown) рассмотрели каждый из 46 448 212 файлов, участвовавших в 37 миллионах компиляций, рассмотренных выше. Они также отслеживали изменения в файлах в течение длительного периода времени. При каждой компиляции учёные проверяли исходный код для того, чтобы присвоить ему одну из 18 возможных категорий ошибок (которые они обозначили буквами от А до R). Они также подсчитали количество времени, которое потребовалось студенту для того, чтобы исправить свою ошибку (путём проверки файла, в котором ошибка уже отсутствовала).
На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.
10 наиболее распространённых ошибок начинающих Java-программистов
АльТадмри и Браун были удивлены, обнаружив, что те ошибки, которые эксперты и преподаватели считали наиболее распространёнными, таковыми на самом деле не являются.
Это может быть опасной тенденцией, заявили они, так как чаще всего подобные ошибки освещаются и даже лелеются в учебниках.
Однако, если обратиться к собранным данным, возникает чёткий рейтинг из 10 наиболее часто встречающихся ошибок (буквами от А до R обозначены 18 категорий ошибок, принимавших участие в исследовании):
С. Несбалансированные скобки, фигурные скобки или кавычки, а также использование этих символов попеременно, например: while (a == 0].
I. Вызов методов с неправильными аргументами или типами аргументов, например: list.get("abc")
.
O. Контрольный поток может достигнуть конец non-void метода без возвращения, например:
public int foo(int x) {
if (x < 0)
return 0;
x += 1;
}
А. Путаница с операторами присвоения (=
) и сравнения (==
), например: if (a = b)
.
N. Игнорирование или отбрасывание возвращаемого значения метода с non-void типом возвращения, например: myObject.toString();
.
B. Использование ==
вместо .equals
для сравнения строк.
M. Попытка вызвать non-static метод так, если бы это был static метод, например: MyClass.toString();
.
R. Попытка класса реализовать интерфейс, но отсутствие реализации всех необходимых методов, например: class Y implements ActionListener { }
.
P. Вызов типов параметров при вызове метода, например: myObject.foo(int x, String s);
.
E. Неправильная расстановка запятых в условиях if
, а также циклах for
и while
, например: if (a == b); return 6;
.
Наиболее частая ошибка — это C (забытые скобки, ошибки в их расстановке) — как раз то, чему пытается противостоять BlueJ, используя подсветку. Данный вывод может быть признаком того, что ошибки типа С будут ещё чаще встречаться в IDE, которые не предоставляют никаких средств для отладки.
Удивительно, но следующие ошибки не попали в ТОП-10:
D. Путаница с использованием операторов «короткого замыкания» (&&
и ||
) и обычных операторов логики (&
и |
).
J. Отсутствие скобок после вызова метода, например: myObject.toString;
.
Q. Присваивание переменной значения с типом, несовместимым с типом переменной, например: int x = myObject.toString();
.
F. Неправильные разделители цикла for
(использование запятой вместо точки с запятой), например: for (int i=0, i < 6, i++)
.
H. Использование ключевых слов в качестве имён переменных или методов, например: int new;
.
На данный момент этот блок не поддерживается, но мы не забыли о нём!Наша команда уже занята его разработкой, он будет доступен в ближайшее время.
Сколько времени занимает учёба на своих ошибках
Кроме того, когда Браун и АльТадмри изучили, сколько времени требуется, чтобы студенты исправили ошибку, стало ясно, что наиболее распространённая ошибка (ошибка С) была также одной из самых быстронаходимых. Другие ошибки было найти труднее. Среди таких ошибок была путаница между операторами &
и |
или &&
и ||
(ошибка D), использование ==
вместо .equals
для сравнения строк (ошибка B) и игнорирование или отбрасывание возвращаемого значения метода с non-void типом возвращения (ошибка N). Они оставались незамеченными более чем 1 000 секунд (после чего отслеживание не продолжалось) или не были найдены вообще никогда.
Медиана, показывающая соотношение время-исправление (time-to fix), показана на рисунке ниже.
P.S. От редактора: забавно, но в статье про самые распространённые ошибки программистов автором была допущена самая распространённая ошибка программистов — не закрыта скобка тега . Будьте внимательны к своему коду!
Эта страница содержит наиболее типичные ошибки, которые я вижу в Java-коде людей, работающих со мной. Статический анализ (мы используем qulice) не можем поймать все ошибки по очевидным причинам, и поэтому я решил перечислить их все здесь.
Все перечисленные ошибки относятся к объектно-ориентированному программированию в целом и к Java в частности.
Имена классов
Ваш класс должен быть абстракцией реального объекта — это не «валидаторы», «контроллеры», «менеджеры» и т.д. Если имя вашего класса заканчивается «-er» — это плохой дизайн: не создавайте объекты, которые заканчиваются на -ER.
Utility классы — это анти-шаблоны
StringUtils, FileUtils и IOUtils от Apache
Конечно, никогда не добавляйте суффиксы или префиксы, чтобы различать интерфейсы и классы. Например, все эти имена ужасно ошибочны: IRecord, IfaceEmployee или RecordInterface. Обычно имя интерфейса — это имя реального объекта, в то время как имя класса должно объяснять детали его реализации. Если ничего не говорится о реализации, назовите ее Default, Simple или нечто подобное. Например:
class SimpleUser implements User {};
class DefaultRecord implements Record {};
class Suffixed implements Name {};
class Validated implements Content {};
Имена методов
Методы могут либо вернуть что-то, либо вернуть void. Если метод возвращает что-то, тогда его имя должно объяснять, что он возвращает, например (не используйте префикс получения никогда):
boolean isValid(String name);
String content();
int ageOf(File file);
Если метод возвращает void, его имя должно объяснить, что оно делает. Например:
void save(File file);
void process(Work work);
void append(File file, String line);
Существует только одно исключение из только что упомянутого правила — методы тестирования для JUnit. Они описаны ниже.
Имена методов тестирования
Имена методов в тестах JUnit должны быть созданы как английские предложения без пробелов. Это проще объяснить на примере
/**
* HttpRequest can return its content in Unicode.
* @throws Exception If test fails
*/
@Test
public void returnsItsContentInUnicode() throws Exception {
}
Важно, чтобы первое предложение вашего JavaDoc начиналось с имени тестируемого класса, за которым следует баннер. Итак, ваше первое предложение всегда должно быть похоже на «кто-то может что-то сделать».
Имя метода будет указано точно так же, но без субъекта. Если я добавлю тему в начале имени метода, я должен получить полное английское предложение, как в примере выше: «HttpRequest возвращает его содержимое в Unicode».
Обратите внимание, что метод тестирования не начинается с может. Только комментарии JavaDoc начинаются с «может». Кроме того, имена методов не должны начинаться с глагола.
Хорошая практика — всегда объявлять тестовые методы как бросание исключения.
Переменные имена
Избегайте составных имен переменных, таких как timeOfDay, firstItem или httpRequest. Я имею в виду переменные как класса, так и в методе. Имя переменной должно быть достаточно длинным, чтобы избежать двусмысленности в его видимости, но не слишком, если это возможно. Имя должно быть существительным в единственном числе или во множественном числе, или соответствующей аббревиатурой.
List<String> names;
void sendThroughProxy(File file, Protocol proto);
private File content;
public HttpRequest request;
Иногда у вас могут быть конфликты между параметрами конструктора и свойствами в классе, если конструктор сохраняет входящие данные в экземпляре объекта. В этом случае я рекомендую создавать аббревиатуры, удаляя гласные.
public class Message {
private String recipient;
public Message(String rcpt) {
this.recipient = rcpt;
}
}
Во многих случаях лучший намек на имя переменной можно узнать, прочитав его имя класса. Просто напишите это маленькой буквой, и вы должны быть хорошими:
File file;
User user;
Branch branch;
Однако никогда не делайте то же самое для примитивных типов, таких как Integer number или String string.
Вы можете также использовать прилагательное, когда есть несколько переменных с разными характеристиками. Например:
String contact(String left, String right);
Конструкторы
Без исключений должен быть только один конструктор, который сохраняет данные в объектных переменных. Все остальные конструкторы должны вызывать эту функцию с разными аргументами.
public class Server {
private String address;
public Server(String uri) {
this.address = uri;
}
public Server(URI uri) {
this(uri.toString());
}
}
Одноразовые переменные
Избегайте одноразовых переменных любой ценой. Под «разовым» я подразумеваю переменные, которые используются только один раз. Как в этом примере:
String name = «data.txt»;
Return new File (name);
Эта вышепеременная используется только один раз, и код должен быть реорганизован в:
Return new File («data.txt»);
Иногда, в очень редких случаях, в основном из-за лучшего форматирования, могут использоваться одноразовые переменные. Тем не менее, старайтесь избегать таких ситуаций любой ценой.
Исключения
Излишне говорить, что вам никогда не следует глотать исключения, а позволять им вздыматься как можно выше. Частные методы всегда должны использовать checked исключения.
Никогда не используйте исключения для управления потоком. Например, этот код неверен:
int size;
try {
Size = this.fileSize ();
} Catch (IOException ex) {
Size = 0;
}
Серьезно, что если в этом IOException говорится, что «диск заполнен?» Будете ли вы по-прежнему считать, что размер файла равен нулю и двигаться дальше?
вдавливание
Для отступа основное правило состоит в том, что скобка должна либо заканчивать линию, либо закрываться в той же строке (обратное правило применяется к закрывающей скобке). Например, следующее неверно, потому что первая скобка не закрыта в одной строке и после нее есть символы. Вторая скобка также находится в затруднении, потому что перед ней есть символы, и она не открывается в одной строке:
Final Файл файла = новый Файл (каталог,
«File.txt»);
Правильный отступ должен выглядеть так:
StringUtils.join (
Arrays.asList (
«первая линия»,
«вторая линия»,
StringUtils.join (
Arrays.asList («a», «b»)
)
),
«separator»
);
Второе важное правило с отступом говорит, что вы должны поставить как можно больше на одной строке — в пределах 80 символов. Пример выше недействителен, так как он может быть сложен:
StringUtils.join (
Arrays.asList (
«Первая строка», «вторая строка»,
StringUtils.join (Arrays.asList («a», «b»))
),
«separator»
);
Избыточные константы
Константы класса должны использоваться, когда вы хотите обмениваться информацией между методами класса, и эта информация является признаком (!) Вашего класса. Не используйте константы в качестве замены строковых или числовых литералов — очень плохая практика, которая приводит к загрязнению кода. Константы (как и любой объект в ООП) должны иметь значение в реальном мире. Какое значение имеют эти константы в реальном мире:
Class Document {
Private static final String D_LETTER = «D»; // плохая практика
Private static final String EXTENSION = «.doc»; // хорошая практика
}
Другой типичной ошибкой является использование констант в модульных тестах, чтобы избежать дублирования строковых / числовых литералов в методах тестирования. Не делай этого! Каждый тестовый метод должен работать со своим собственным набором входных значений.
Используйте новые тексты и цифры в каждом новом методе тестирования. Они независимы. Итак, почему они должны использовать одни и те же входные константы?
Связывание тестовых данных
Это пример связывания данных в тестовом методе:
Пользователь user = новый Пользователь («Jeff»);
// какой-то другой код здесь
MatcherAssert.assertThat (user.name (), Matchers.equalTo («Jeff»));
В последней строке мы соединяем «Jeff» с тем же строковым литералом из первой строки. Если через несколько месяцев кто-то захочет изменить значение на третьей строке, ему придется потратить дополнительное время на то, чтобы найти, где еще «Jeff» используется тем же методом.
Чтобы избежать этой связи данных, вы должны ввести переменную. Подробнее об этом здесь: Несколько мыслей о модульных испытаниях.
Вступление
В этом разделе описываются некоторые распространенные ошибки, допущенные новичками на Java.
Это включает в себя любые распространенные ошибки в использовании языка Java или понимание среды выполнения.
Ошибки, связанные с конкретными API-интерфейсами, могут быть описаны в разделах, относящихся к этим API. Строки — это особый случай; они описаны в Спецификации языка Java. Подробности, отличные от распространенных ошибок, можно описать в этом разделе на строках .
Pitfall: использование == для сравнения объектов примитивных оберток, таких как Integer
(Эта ошибка относится одинаково ко всем примитивным типам обертки, но мы проиллюстрируем ее для Integer
и int
.)
При работе с объектами Integer
возникает соблазн использовать ==
для сравнения значений, потому что это то, что вы бы сделали с значениями int
. И в некоторых случаях это будет работать:
Integer int1_1 = Integer.valueOf("1");
Integer int1_2 = Integer.valueOf(1);
System.out.println("int1_1 == int1_2: " + (int1_1 == int1_2)); // true
System.out.println("int1_1 equals int1_2: " + int1_1.equals(int1_2)); // true
Здесь мы создали два объекта Integer
со значением 1
и сравним их (в этом случае мы создали один из String
и один из int
literal. Существуют и другие альтернативы). Кроме того, мы видим, что оба метода сравнения ( ==
и equals
) дают true
.
Такое поведение меняется, когда мы выбираем разные значения:
Integer int2_1 = Integer.valueOf("1000");
Integer int2_2 = Integer.valueOf(1000);
System.out.println("int2_1 == int2_2: " + (int2_1 == int2_2)); // false
System.out.println("int2_1 equals int2_2: " + int2_1.equals(int2_2)); // true
В этом случае, только equals
сравнение дает правильный результат.
Причиной этого различия в поведении является то, что JVM поддерживает кеш объектов Integer
для диапазона от -128 до 127. (Верхнее значение может быть переопределено системным свойством «java.lang.Integer.IntegerCache.high» или JVM-аргумент «-XX: AutoBoxCacheMax = размер»). Для значений в этом диапазоне Integer.valueOf()
вернет кешированное значение, а не создает новый.
Таким образом, в первом примере Integer.valueOf(1)
и Integer.valueOf("1")
возвращают тот же кешированный экземпляр Integer
. Напротив, во втором примере Integer.valueOf(1000)
и Integer.valueOf("1000")
создали и вернули новые объекты Integer
.
Оператор ==
для эталонных типов тестов для ссылочного равенства (т. Е. Одного и того же объекта). Поэтому в первом примере int1_1 == int1_2
true
потому что ссылки одинаковы. Во втором примере int2_1 == int2_2
является ложным, потому что ссылки разные.
Pitfall: забыв о свободных ресурсах
Каждый раз, когда программа открывает ресурс, такой как файл или сетевое соединение, важно освободить ресурс, как только вы закончите его использование. Аналогичная осторожность должна быть предпринята, если во время операций на таких ресурсах следует выбросить какие-либо исключения. Можно утверждать, что FileInputStream
имеет финализатор, который вызывает метод close()
в событии сбора мусора; однако, поскольку мы не можем быть уверены, когда начнется цикл сбора мусора, поток ввода может потреблять компьютерные ресурсы в течение неопределенного периода времени. Ресурс должен быть закрыт в finally
разделе блока try-catch:
Java SE 7
private static void printFileJava6() throws IOException {
FileInputStream input;
try {
input = new FileInputStream("file.txt");
int data = input.read();
while (data != -1){
System.out.print((char) data);
data = input.read();
}
} finally {
if (input != null) {
input.close();
}
}
}
Так как Java 7 действительно полезный и аккуратный оператор, введенный в Java 7, особенно для этого случая, называемый try-with-resources:
Java SE 7
private static void printFileJava7() throws IOException {
try (FileInputStream input = new FileInputStream("file.txt")) {
int data = input.read();
while (data != -1){
System.out.print((char) data);
data = input.read();
}
}
}
Оператор try-with-resources может использоваться с любым объектом, который реализует интерфейс Closeable
или AutoCloseable
. Он гарантирует, что каждый ресурс будет закрыт до конца инструкции. Разница между двумя интерфейсами заключается в том, что метод close()
Closeable
вызывает Closeable
IOException
которое должно быть обработано каким-то образом.
В тех случаях, когда ресурс уже открыт, но после его использования он должен быть безопасно закрыт, его можно назначить локальной переменной внутри try-with-resources
Java SE 7
private static void printFileJava7(InputStream extResource) throws IOException {
try (InputStream input = extResource) {
... //access resource
}
}
Локальная переменная ресурса, созданная в конструкторе try-with-resources, фактически является окончательной.
Pitfall: утечки памяти
Java автоматически управляет памятью. Вы не обязаны освобождать память вручную. Память объекта в куче может быть освобождена сборщиком мусора, когда объект больше не доступен доступной нитью.
Тем не менее, вы можете предотвратить освобождение памяти, позволяя объектам быть доступными, которые больше не нужны. Если вы называете это утечкой памяти или упаковкой памяти, результат будет таким же — ненужное увеличение выделенной памяти.
Утечки памяти в Java могут происходить по-разному, но наиболее распространенной причиной являются вечные ссылки на объекты, поскольку сборщик мусора не может удалить объекты из кучи, пока есть ссылки на них.
Статические поля
Можно создать такую ссылку путем определения класса со static
полем, содержащим некоторую коллекцию объектов, и забыть установить это static
поле в null
после того, как сбор больше не нужен. static
поля считаются корнями GC и никогда не собираются. Другой проблемой является утечка в памяти без кучи при использовании JNI .
Утечка класса загрузчика
Тем не менее, самым коварным типом утечки памяти является утечка загрузчика класса. Класс loader содержит ссылку на каждый класс, который он загрузил, и каждый класс содержит ссылку на свой загрузчик классов. У каждого объекта есть ссылка на его класс. Поэтому, если даже один объект класса, загружаемый загрузчиком классов, не является мусором, может быть собрано не один класс, загруженный загрузчиком этого класса. Поскольку каждый класс также ссылается на его статические поля, они также не могут быть собраны.
Утечка утечки. Пример утечки утечки может выглядеть следующим образом:
final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
final Deque<BigDecimal> numbers = new LinkedBlockingDeque<>();
final BigDecimal divisor = new BigDecimal(51);
scheduledExecutorService.scheduleAtFixedRate(() -> {
BigDecimal number = numbers.peekLast();
if (number != null && number.remainder(divisor).byteValue() == 0) {
System.out.println("Number: " + number);
System.out.println("Deque size: " + numbers.size());
}
}, 10, 10, TimeUnit.MILLISECONDS);
scheduledExecutorService.scheduleAtFixedRate(() -> {
numbers.add(new BigDecimal(System.currentTimeMillis()));
}, 10, 10, TimeUnit.MILLISECONDS);
try {
scheduledExecutorService.awaitTermination(1, TimeUnit.DAYS);
} catch (InterruptedException e) {
e.printStackTrace();
}
В этом примере создаются две запланированные задачи. Первая задача берет последнее число из дека, называемого numbers
, и, если число делится на 51, оно печатает число и размер дека. Вторая задача помещает числа в deque. Обе задачи запланированы с фиксированной скоростью, и они запускаются каждые 10 мс.
Если код выполнен, вы увидите, что размер deque постоянно увеличивается. Это в конечном итоге приведет к тому, что deque будет заполнено объектами, которые потребляют всю доступную память кучи.
Чтобы предотвратить это при сохранении семантики этой программы, мы можем использовать другой метод для pollLast
чисел из deque: pollLast
. В отличие от метода peekLast
, pollLast
возвращает элемент и удаляет его из deque, в то время как peekLast
возвращает только последний элемент.
Pitfall: использование == для сравнения строк
Общей ошибкой для начинающих Java является использование оператора ==
чтобы проверить, равны ли две строки. Например:
public class Hello {
public static void main(String[] args) {
if (args.length > 0) {
if (args[0] == "hello") {
System.out.println("Hello back to you");
} else {
System.out.println("Are you feeling grumpy today?");
}
}
}
}
Вышеупомянутая программа должна проверять первый аргумент командной строки и печатать разные сообщения, когда она не является словом «привет». Но проблема в том, что это не сработает. Эта программа выведет «Вы чувствуете себя сердитой сегодня?» независимо от того, что первый аргумент командной строки.
В этом конкретном случае String
«hello» помещается в пул строк, в то время как String
args [0] находится в куче. Это означает, что есть два объекта, представляющих один и тот же литерал, каждый со своей ссылкой. Поскольку ==
тесты для ссылок, а не фактическое равенство, сравнение даст ложь большую часть времени. Это не означает, что это всегда будет так.
Когда вы используете ==
для тестирования строк, то, что вы на самом деле тестируете, — это два объекта String
— один и тот же объект Java. К сожалению, это не то, что означает равенство строк в Java. Фактически, правильным способом тестирования строк является использование метода equals(Object)
. Для пары строк мы обычно хотим проверить, состоят ли они из одних и тех же символов в том же порядке.
public class Hello2 {
public static void main(String[] args) {
if (args.length > 0) {
if (args[0].equals("hello")) {
System.out.println("Hello back to you");
} else {
System.out.println("Are you feeling grumpy today?");
}
}
}
}
Но на самом деле это становится хуже. Проблема заключается в том, что ==
даст ожидаемый ответ в некоторых обстоятельствах. Например
public class Test1 {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "hello";
if (s1 == s2) {
System.out.println("same");
} else {
System.out.println("different");
}
}
}
Интересно, что это напечатает «тот же», хотя мы тестируем строки неверным образом. Это почему? Поскольку спецификация языка Java (раздел 3.10.5: литералы строк) предусматривает, что любые две строки >> литералы <<, состоящие из одних и тех же символов, будут фактически представлены одним и тем же объектом Java. Следовательно, тест ==
даст истину для равных литералов. (Строковые литералы «интернированы» и добавляются в общий «пул строк», когда ваш код загружен, но это фактически деталь реализации.)
Чтобы добавить к путанице, спецификация языка Java также предусматривает, что когда у вас есть выражение постоянной времени компиляции, которое объединяет два строковых литерала, это эквивалентно одному литералу. Таким образом:
public class Test1 {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "hel" + "lo";
String s3 = " mum";
if (s1 == s2) {
System.out.println("1. same");
} else {
System.out.println("1. different");
}
if (s1 + s3 == "hello mum") {
System.out.println("2. same");
} else {
System.out.println("2. different");
}
}
}
Это будет выводить «1. same» и «2. different». В первом случае выражение +
оценивается во время компиляции, и мы сравниваем один объект String
с самим собой. Во втором случае он оценивается во время выполнения, и мы сравниваем два разных объекта String
Таким образом, использование ==
для тестирования строк в Java почти всегда неверно, но не гарантированно дает неправильный ответ.
Pitfall: тестирование файла перед попыткой его открыть.
Некоторые люди рекомендуют вам применять различные тесты к файлу, прежде чем пытаться открыть его, чтобы обеспечить лучшую диагностику или избежать устранения исключений. Например, этот метод пытается проверить, соответствует ли path
читаемому файлу:
public static File getValidatedFile(String path) throws IOException {
File f = new File(path);
if (!f.exists()) throw new IOException("Error: not found: " + path);
if (!f.isFile()) throw new IOException("Error: Is a directory: " + path);
if (!f.canRead()) throw new IOException("Error: cannot read file: " + path);
return f;
}
Вы можете использовать вышеупомянутый метод следующим образом:
File f = null;
try {
f = getValidatedFile("somefile");
} catch (IOException ex) {
System.err.println(ex.getMessage());
return;
}
try (InputStream is = new FileInputStream(file)) {
// Read data etc.
}
Первая проблема заключается в сигнатуре для FileInputStream(File)
потому что компилятор по-прежнему настаивает на том, что мы поймаем IOException
здесь или дальше по стеку.
Вторая проблема заключается в том, что проверки, выполняемые методом getValidatedFile
, не гарантируют успеха FileInputStream
.
-
Условия гонки: другой поток или отдельный процесс может переименовать файл, удалить файл или удалить доступ для чтения после возвращения
getValidatedFile
. Это приведет к «IOException
»IOException
без специального сообщения. -
Есть те случаи, которые не охватываются этими тестами. Например, в системе с SELinux в режиме принудительного исполнения попытка чтения файла может завершиться неудачей, несмотря на то, что
canRead()
возвращаетtrue
.
Третья проблема заключается в том, что тесты неэффективны. Например, exists
вызовы isFile
и canRead
, каждый из которых выполняет команду syscall для выполнения требуемой проверки. Затем открывается другой syscall, чтобы открыть файл, который повторяет те же проверки за кулисами.
Короче говоря, такие методы, как getValidatedFile
, ошибочны. Лучше просто попытаться открыть файл и обработать исключение:
try (InputStream is = new FileInputStream("somefile")) {
// Read data etc.
} catch (IOException ex) {
System.err.println("IO Error processing 'somefile': " + ex.getMessage());
return;
}
Если вы хотите различать ошибки ввода-вывода при открытии и чтении, вы можете использовать вложенный try / catch. Если вы хотите улучшить диагностику открытых сбоев, вы можете выполнить проверки exists
, isFile
и canRead
в обработчике.
Pitfall: мышление переменных как объектов
Никакая переменная Java не представляет объект.
String foo; // NOT AN OBJECT
Ни один Java-массив не содержит объектов.
String bar[] = new String[100]; // No member is an object.
Если вы ошибочно считаете переменные как объекты, то реальное поведение языка Java удивит вас.
-
Для переменных Java, которые имеют примитивный тип (например,
int
илиfloat
), переменная содержит копию значения. Все копии примитивного значения неразличимы; т.е. для номера один существует только одно значениеint
. Примитивные значения не являются объектами, и они не ведут себя как объекты. -
Для переменных Java, которые имеют ссылочный тип (либо класс, либо тип массива), переменная содержит ссылку. Все копии ссылки неразличимы. Ссылки могут указывать на объекты, или они могут быть
null
что означает, что они указывают на отсутствие объекта. Однако они не являются объектами, и они не ведут себя как объекты.
Переменные не являются объектами в любом случае, и они не содержат объектов в любом случае. Они могут содержать ссылки на объекты , но это говорит что-то другое.
Пример класса
В следующих примерах используется этот класс, который представляет точку в 2D пространстве.
public final class MutableLocation {
public int x;
public int y;
public MutableLocation(int x, int y) {
this.x = x;
this.y = y;
}
public boolean equals(Object other) {
if (!(other instanceof MutableLocation) {
return false;
}
MutableLocation that = (MutableLocation) other;
return this.x == that.x && this.y == that.y;
}
}
Экземпляр этого класса представляет собой объект, который имеет два поля x
и y
которые имеют тип int
.
У нас может быть много экземпляров класса MutableLocation
. Некоторые из них будут представлять одинаковые местоположения в 2D-пространстве; т.е. соответствующие значения x
и y
будут совпадать. Другие будут представлять разные местоположения.
Несколько переменных могут указывать на один и тот же объект
MutableLocation here = new MutableLocation(1, 2);
MutableLocation there = here;
MutableLocation elsewhere = new MutableLocation(1, 2);
В приведенном выше MutableLocation
мы объявили here
три переменные, there
и в elsewhere
, elsewhere
могут храниться ссылки на объекты MutableLocation
.
Если вы (неправильно) считаете эти переменные объектами, то вы, скорее всего, неправильно понимаете утверждения:
- Скопируйте местоположение «[1, 2]»
here
- Скопируйте местоположение «[1, 2]»
there
- Скопируйте местоположение «[1, 2]» в
elsewhere
Из этого вы можете сделать вывод, что у нас есть три независимых объекта в трех переменных. Фактически, только два объекта созданы выше. Переменные here
и there
действительно относятся к одному и тому же объекту.
Мы можем это продемонстрировать. Предполагая объявления переменных, как указано выше:
System.out.println("BEFORE: here.x is " + here.x + ", there.x is " + there.x +
"elsewhere.x is " + elsewhere.x);
here.x = 42;
System.out.println("AFTER: here.x is " + here.x + ", there.x is " + there.x +
"elsewhere.x is " + elsewhere.x);
Это выведет следующее:
BEFORE: here.x is 1, there.x is 1, elsewhere.x is 1
AFTER: here.x is 42, there.x is 42, elsewhere.x is 1
Мы присвоили новое значение here.x
и изменили значение, которое мы видим через there.x
. Они относятся к одному и тому же объекту. Но значение, которое мы видим через elsewhere.x
, не изменилось, поэтому в elsewhere
должно быть ссылка на другой объект.
Если переменная была объектом, тогда присваивание here.x = 42
не изменилось бы there.x
. there.x
Оператор равенства НЕ проверяет, что два объекта равны
Применение оператора равенства ( ==
) для сравнения значений значений, если значения относятся к одному и тому же объекту. Он не проверяет, являются ли два (разных) объекта «равными» в интуитивном смысле.
MutableLocation here = new MutableLocation(1, 2);
MutableLocation there = here;
MutableLocation elsewhere = new MutableLocation(1, 2);
if (here == there) {
System.out.println("here is there");
}
if (here == elsewhere) {
System.out.println("here is elsewhere");
}
Это напечатает «здесь есть», но он не будет печатать «здесь где-то еще». (Ссылки here
и в elsewhere
предназначены для двух разных объектов.)
Напротив, если мы назовем метод equals(Object)
который мы реализовали выше, мы будем тестировать, если два экземпляра MutableLocation
имеют одинаковое расположение.
if (here.equals(there)) {
System.out.println("here equals there");
}
if (here.equals(elsewhere)) {
System.out.println("here equals elsewhere");
}
Это напечатает оба сообщения. В частности, здесь here.equals(elsewhere)
возвращает true
потому что семантические критерии, которые мы выбрали для равенства двух объектов MutableLocation
, были выполнены.
Вызов метода НЕ пропускает объекты вообще
Вызов метода Java использует pass по значению 1 для передачи аргументов и возврата результата.
Когда вы передаете ссылочное значение методу, вы фактически передаете ссылку на объект по значению , а это значит, что он создает копию ссылки на объект.
Пока обе ссылки на объекты все еще указывают на один и тот же объект, вы можете изменить этот объект из любой ссылки, и это то, что вызывает путаницу для некоторых.
Однако вы не передаете объект по ссылке 2 . Различие заключается в том, что если копия ссылки на объект модифицирована, чтобы указать на другой объект, исходная ссылка на объект все равно укажет на исходный объект.
void f(MutableLocation foo) {
foo = new MutableLocation(3, 4); // Point local foo at a different object.
}
void g() {
MutableLocation foo = MutableLocation(1, 2);
f(foo);
System.out.println("foo.x is " + foo.x); // Prints "foo.x is 1".
}
Также вы не передаете копию объекта.
void f(MutableLocation foo) {
foo.x = 42;
}
void g() {
MutableLocation foo = new MutableLocation(0, 0);
f(foo);
System.out.println("foo.x is " + foo.x); // Prints "foo.x is 42"
}
1 — В таких языках, как Python и Ruby, термин «pass by sharing» является предпочтительным для «pass by value» объекта / ссылки.
2 — Термин «передавать по ссылке» или «вызов по ссылке» имеет очень специфическое значение в терминологии языка программирования. Фактически это означает, что вы передаете адрес переменной или элемента массива , так что, когда вызываемый метод присваивает новое значение формальному аргументу, он изменяет значение в исходной переменной. Java не поддерживает это. Для более подробного описания различных механизмов передачи параметров см. Https://en.wikipedia.org/wiki/Evaluation_strategy .
Pitfall: объединение назначений и побочных эффектов
Иногда мы видим вопросы StackOverflow Java (и вопросы C или C ++), которые задают что-то вроде этого:
i += a[i++] + b[i--];
оценивает … для некоторых известных начальных состояний i
, a
и b
.
Вообще говоря:
- для Java ответ всегда задается 1 , но неочевидно, и часто трудно понять
- для C и C ++ ответ часто не указан.
Такие примеры часто используются на экзаменах или собеседованиях в качестве попытки выяснить, действительно ли учащийся или собеседник понимает, как выражение оценки действительно работает на языке программирования Java. Это, возможно, законно, как «тест знаний», но это не значит, что вы должны когда-либо делать это в реальной программе.
Чтобы проиллюстрировать, следующий простой, казалось бы, простой пример появился несколько раз в вопросах StackOverflow (например, этот ). В некоторых случаях это кажется подлинной ошибкой в чьем-то коде.
int a = 1;
a = a++;
System.out.println(a); // What does this print.
Большинство программистов (включая экспертов Java), быстро читающих эти утверждения, скажут, что он выводит 2
. Фактически, он выводит 1
. Подробное объяснение причин, пожалуйста, прочитайте этот ответ .
Однако реальный вынос из этого и подобных примеров является то , что любое заявление Java , что и присваивает и побочные эффекты и та же переменная будет в лучшем случае трудно понять, а в худшем случае совершенно ввести в заблуждение. Вы должны избегать написания кода, подобного этому.
1 — по модулю потенциальных проблем с моделью памяти Java, если переменные или объекты видны другим потокам.
Pitfall: Не понимая, что String является неизменным классом
Новые программисты Java часто забывают или не могут полностью понять, что класс Java String
неизменен. Это приводит к таким проблемам, как в следующем примере:
public class Shout {
public static void main(String[] args) {
for (String s : args) {
s.toUpperCase();
System.out.print(s);
System.out.print(" ");
}
System.out.println();
}
}
Вышеприведенный код должен печатать аргументы командной строки в верхнем регистре. К сожалению, это не сработает, случай аргументов не изменяется. Проблема заключается в следующем:
s.toUpperCase();
Вы можете подумать, что вызов toUpperCase()
изменит s
на верхнюю строку. Это не так. Это не может! String
объекты неизменяемы. Они не могут быть изменены.
На самом деле метод toUpperCase()
возвращает объект String
который является строчной версией String
которую вы вызываете ее. Вероятно, это будет новый объект String
, но если s
уже был в верхнем регистре, результатом может быть существующая строка.
Поэтому, чтобы эффективно использовать этот метод, вам нужно использовать объект, возвращенный вызовом метода; например:
s = s.toUpperCase();
Фактически правило «строки никогда не изменяется» применяется ко всем методам String
. Если вы помните это, тогда вы можете избежать ошибок целой категории начинающих.
Это третья статья о распространенных ошибках в Java. Давайте посмотрим на следующие 5 ошибок, которые люди делают в Java. Первые 10 — в статье ниже.
Ниже приведены первые 10 распространенных ошибок, которые люди совершают в java.
Смотрим следующие 5!
11 – Использование непарных скобок
Эту распространенную ошибку легко понять и обычно (но не всегда!) легко решить, как подскажет компилятор.
Пример:
(((a + b) * c) / (d + e)* f
Где проблема в выражении выше?
Первая скобка непарная. Чтобы решить эту проблему, мы можем добавить скобку после «f» или мы можем удалить первую скобку.
Обратите внимание, рано или поздно вы забудете соединить скобу. Эта ошибка является частью тех ошибок, которые мы совершаем, когда устаем.
12 — использование точки с запятой после оператора «если»
It is:
if(a > b)
{
//сделать что-то
}
и не:
if(a > b);
{
//сделать что-то
}
В последнем добавлена точка с запятой («;») после оператора «если». Это ошибки!
Не нужно ставить точку с запятой после оператора «если».
13 — Сравните строки с помощью оператора «==»
Пусть будут «string1» и «string2» две строковые переменные.
Написание «string1 == string2» не является ошибкой, но две строки не сравниваются! Приведенная выше логическая операция возвращает true, если две строки указывают на одно и то же место в памяти. Обращать внимание!
Чтобы сравнить две строки и убедиться, что они одинаковы, используйте метод «равно». Пример:
Здесь мы сравниваем строки «s1» и «s2» и печатаем сообщение, если они совпадают.
Лексикографическое сравнение
Для сравнения двух строк вы также можете использовать метод «compareTo». Он сравнивает две строки и:
- Возвращает отрицательное число, если первая строка меньше второй.
- Возвращает положительное число, если первая строка больше второй.
- Возвращает 0 (ноль), если две строки являются одной и той же строкой.
Пример:
Осторожно используйте оператор «==», метод «equals» и метод «compareTo».
14 — Бесконечные петли
Давайте посмотрим пример:
Вышеприведенный цикл никогда не завершится. Почему? Переменная «a» всегда будет равна «2», поэтому цикл while будет выполняться бесконечно, так как условие «a ‹ 20» всегда будет истинным.
Решение
Внутри цикла while увеличивает переменную «a». Добавьте оператор как «a++;» или «а+=40;». Это всего лишь два примера. Однако переменная «a» должна быть каким-то образом увеличена.
15 – Доступ к несуществующим элементам массива
Посмотрите на следующий код:
В чем проблема?
«10» не является индексом массива! Массив состоит из 10 элементов, а его индексы начинаются с «0» и заканчиваются на «9». Будьте осторожны, так как компилятор не будет жаловаться и во время выполнения будет сгенерировано исключение java.lang.ArrayIndexOutOfBoundsException. Всегда обращайтесь к массиву только с его индексами!
Спасибо за то, что прочитали мою статью. Надеюсь, я добавил ценность вашим знаниям.
Рассмотрите возможность подписаться на меня и подписаться ниже, указав свой адрес электронной почты, чтобы узнавать, когда я публикую свои новые ежедневные статьи.
Вам понравилось читать?
Поддержите меня. Воспользуйтесь ссылкой ниже, чтобы стать участником Medium.
Поддержите мое письмо, став участником Medium сегодня, используя мою ссылку, и получите полный доступ ко всем историям на Medium. Нажмите Участник Medium выше, чтобы ссылка на сайт. Никаких дополнительных затрат для вас!