Статья отражает личный опыт автора – заядлого программиста микроконтроллеров, которому после многолетнего опыта микроконтроллерной разработки на языке С (и немного на С++) довелось участвовать в крупном Java-проекте по разработке ПО для ТВ-приставок под управлением Android. В ходе этого проекта удалось собрать заметки об интересных различиях языков Java и C/C++, оценить разные подходы к написанию программ. Статья не претендует на роль справочника, в ней не рассматривается эффективность и производительность Java-программ. Это скорее сборник личных наблюдений. Если не указано иное, то речь идет о версии Java SE 7.

Различия в синтаксисе и управляющие конструкции


Если говорить кратко – различия минимальные, синтаксис очень похож. Блоки кода также формируются парой фигурных скобок {}. Правила составления идентификаторов – такие же, как и для языка C/С++. Список ключевых слов почти такой же, как в языке C/С++. Встроенные типы данных – подобны таковым в C/С++. Массивы – все также объявляются с помощью квадратных скобок.

Управляющие конструкции if-else, while, do-while, for, switch тоже почти полностью идентичные. Примечательно, что в Java остались знакомые C-программистам метки (те, которые используются с ключевым словом goto и применение которых категорически не рекомендуется). Однако из Java исключили возможность перехода на метку с помощью goto. Метки следует использовать только для выхода из вложенных циклов:

outer:
for (int i = 0; i < 5; i++) {
    inner:
    for (int j = 0; j < 5; j++) {
        if (i == 2) break inner;
        if (i == 3) continue outer;
    }
}

Для улучшения читаемости программ в Java добавлена интересная возможность разделять разряды длинных чисел символом подчеркивания:

int value1 =  1_500_000;
long value2 = 0xAA_BB_CC_DD;

Внешне программа на Java не сильно отличается от программы на знакомом C. Главное визуальное отличие – Java не допускает «свободно» расположенных в исходном файле функций, переменных, определений новых типов (структур), констант и прочего. Java – объектно-ориентированный язык, поэтому все программные сущности должны принадлежать какому-либо классу. Еще одно значительное отличие – отсутствие препроцессора. Об этих двух различиях подробнее рассказано ниже.

Объектный подход в языке C


Когда мы пишем на языке C большие программы, по сути нам приходится работать с объектами. Роль объекта здесь выполняет структура, которая описывает некую сущность «реального мира»:

// Объявление структуры – «класса»
struct Data {
    int field;
    char *str;
    /* ... */
};

Также в C есть методы обработки «объектов»-структур – функции. Однако функции по сути не объединены с данными. Да, их обычно помещают в один файл, но в «типовую» функцию каждый раз необходимо передавать указатель на обрабатываемый объект:

int process(struct Data *ptr, int arg1, const char *arg2) {
    /* ... */
    return result_code;
}

Пользоваться «объектом» можно только после выделения памяти для его хранения:

Data *data = malloc(sizeof(Data));

В программе на C обычно определяют функцию, которая отвечает за начальную инициализацию «объекта» перед первым его использованием:

void init(struct Data *data) {
    data->field = 1541;
    data->str = NULL;
}

Тогда жизненный цикл «объекта» в C обычно такой:

/* Выделить память для "объекта" */
struct Data *data = malloc(sizeof(Data));

/* Инициализировать "объект" */
init(data);

/* Изменить состояние "объекта" */
process(data, 0, "string");

/* Освободить память, занимаемую "объектом" когда он уже не нужен. */
free(data);

Теперь перечислим возможные ошибки времени выполнения, которые могут быть допущены программистом в жизненном цикле «объекта»:

  1. Забыть выделить память для «объекта»
  2. Указать неверный объем выделяемой памяти
  3. Забыть проинициализировать «объект»
  4. Забыть освободить память после окончания использования объекта

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

Объектный подход Java


Сталкиваясь с ООП – объектно-ориентированным программированием, вы наверняка слышали об одном из китов ООП – инкапсуляции. В Java, в отличие от C, данные и методы их обработки объединены вместе и представляют собой «истинные» объекты. В терминах ООП это называют инкапсуляцией. Класс – это описание объекта, ближайший аналог класса в C – определение нового типа с помощью typedef struct. В терминах Java те функции, которые принадлежат классу, называют методами.

// Описание класса
class Entity {
    public int field;  // Поле данных
    public String str; // Поле данных

    // Метод
    public int process(int arg1, String arg2) {
        /* ... */
        return resultCode;
    }
    
    // Конструктор
    public Entity() {
        field = 1541;
        str = "value";
    }
}

В основе идеологии языка Java лежит утверждение «все есть объект». Поэтому не удивительно, что Java запрещает создавать как методы (функции), так и поля данных (переменные) отдельно от класса. Даже привычный метод main(), с которого начинается выполнение программы, должен принадлежать одному из классов.

Описание класса в Java – это аналог объявления структуры в C. Описав класс, вы ничего не создаете в памяти. Объект данного класса появляется в момент своего создания оператором new. Создание объекта в Java – это аналог выделения памяти в языке C, но, в отличие от последнего, во время создания автоматически вызывается специальный метод – конструктор объекта. Конструктор берет на себя роль начальной инициализации объекта – аналог функции init(), рассмотренной ранее. Имя конструктора обязательно должно совпадать с именем класса. Конструктор не может возвращать значение.

Жизненный цикл объекта в программе на Java выглядит следующим образом:

// Создать объект (выделить память и инициализировать, вызвав конструктор)
Entity entity = new Entity();

// Изменить состояние объекта
entity.process(123, "argument");

Заметьте, что количество возможных ошибок в программе на Java значительно меньше, чем в программе на C. Да, по-прежнему можно забыть создать объект перед первым использованием (что впрочем приведет к легко отлаживаемому исключению NullPointerException), но то что касается остальных ошибок, присущих программам на C, ситуация коренным образом меняется:

  1. В Java отсутствует оператор sizeof(). Компилятор Java сам рассчитывает объем памяти для хранения объекта. Следовательно, невозможно указать неверный размер выделяемой области.
  2. Инициализация объекта происходит в момент создания. Невозможно забыть о проведении инициализации.
  3. Память, занимаемую объектом, не нужно освобождать, эту работу выполняет сборщик мусора. Невозможно забыть удалить объект после использования – меньше вероятность появления эффекта «утечки памяти».

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

Память и сборщик мусора


В Java сохранены знакомые C/C++-программисту понятия кучи и стека. При создании объекта оператором new память для хранения объекта заимствуется из кучи. Однако ссылка на объект (ссылка – аналог указателя), если созданный объект не входит в состав другого объекта, размещается в стеке. В куче хранятся «тела» объектов, а в стеке – локальные переменные: ссылки на объекты и примитивные типы. Если куча существует на протяжении выполнения программы и доступна для всех потоков программы, то стек относится к методу и существует только во время его выполнения, а также недоступен для других потоков программы.

В Java нет необходимости и даже более того – нельзя вручную освободить память, занимаемую объектом. Эту работу выполняет сборщик мусора в автоматическом режиме. Среда выполнения следит, можно ли из текущего места программы достигнуть каждого объекта в куче, переходя по ссылкам от объекта к объекту. Если нет – то такой объект признается «мусором» и становится кандидатом на удаление.

Важно отметить, что само удаление происходит не в момент, когда объект «перестал быть нужен» – решение об удалении принимает сборщик мусора, и удаление может откладываться сколько угодно, вплоть до момента окончания работы программы.

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

Говоря о локальных переменных, следует вспомнить подход Java к их инициализации. Если в C/C++ неинициализированная локальная переменная содержит случайное значение, то компилятор Java попросту не позволит оставить ее неинициализированной:

int i;  // Неинициализированная переменная.
System.out.println("" + i);  // Ошибка компиляции!

Ссылки – замена указателям


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

// Переменная entity – ссылочный тип.
Entity entity = new Entity();

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

// Передача структуры по значению.
int func(Data data);
А можно было – передавать через указатель:

// Передача структуры через указатель.
void process(Data *data);

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

В Java оставили только один способ передачи объекта в метод – по ссылке. Передача по ссылке в Java – аналог передачи через указатель в C:
  • копирования (клонирования) памяти не происходит,
  • фактически передается адрес размещения данного объекта.

Однако, в отличии от указателя языка C, ссылку Java нельзя инкрементировать/декрементировать. «Бегать» по элементам массива с помощью ссылки на него в Java не получится. Все, что можно сделать со ссылкой, это присвоить ей другое значение.

Безусловно отсутствие указателей как таковых уменьшает количество возможных ошибок, однако в языке остался аналог нулевого указателя – нулевая ссылка, обозначаемая ключевым словом null.

Нулевая ссылка – это головная боль Java-программиста, т.к. вынуждает перед использованием ссылки на объект либо проверить ее на null, либо обрабатывать исключения NullPointerException. Если этого не делать, то произойдет крах программы.

Итак, все объекты в Java передаются через ссылки. Примитивные же типы данных (int, long, char...) – передаются по значению (подробнее о примитивах – ниже).

Особенности ссылок в Java


Доступ к любому объекту в программе осуществляется через ссылку – это однозначно положительно сказывается на производительности, но может преподнести сюрприз новичку:

// Создать объект, пусть ссылка entity1 указывает не него.
Entity entity1 = new Entity();
entity1.field = 123;

// Создать ссылку entity2, которая указывает на объект entity1.
// Новый объект не создается! Память не выделяется!
Entity entity2 = entity1;

// Теперь ссылки entity1 и entity2 указывают на один и тот же объект в памяти.
entity2.field = 777;

// Теперь entity1.field равно 777.
System.out.println(entity1.field);

Аргументы методов и возвращаемые значения – все передается через ссылку. Кроме преимуществ в этом кроется недостаток по сравнению с языками C/С++, где мы можем явно запретить функции менять значение, переданное через указатель с помощью квалификатора типа const:

void func(const struct Data* data) {
    // Ошибка компиляции!
    // Попытка записи в объект, доступный только для чтения!
    data->field = 0;
}

То есть язык C позволяет отследить эту ошибку на этапе компиляции. В Java так же есть ключевое слово const, но оно зарезервировано для будущих версий и сейчас вообще не используется. Его роль в некоторой степени призвано выполнять ключевое слово final. Однако оно не позволяет защитить передаваемый в метод объект от изменений:

public class Main {
    void func(final Entity data) {
        // Компилятор не выдает ошибок.
        // Не смотря на final, значение поля успешно обнуляется.
        data.field = 0;
    }
}

Все дело в том, что ключевое слово final в данном случае применяется к ссылке, а не к объекту, на который эта ссылка указывает. Если применить final к примитиву, то компилятор ведет себя так, как и ожидается:

void func(final int value) {
    // Ошибка на стадии компиляции.
    value = 0;
}

Ссылки Java очень похожи на ссылки языка C++.

Примитивы Java


Каждый объект Java помимо полей данных содержит вспомогательную информацию. Если мы хотим оперировать, например, отдельными байтами и каждый байт представлен объектом, то в случае массива байт накладные расходы памяти могут многократно превысить полезный объем.
Чтобы Java оставалась достаточно эффективной и в случаях, описанных выше, в язык была добавлена поддержка примитивных типов – примитивов.
Примитив Вид Разрядность, бит Возможный аналог в C
byte Целочисленные 8 char
short 16 short
char 16 wchar_t
int 32 int (long)
long 64 long
float Числа с плавающей точкой 32 float
double 64 double
boolean Логический - int (C89) / bool (C99)

Все примитивы имеют свои аналоги в языке C. Однако стандарт C не определяет точный размер целочисленных типов, вместо этого фиксируется диапазон значений, которые может хранить данный тип. Зачастую программист хочет обеспечить одинаковую разрядность для разных машин, что приводит к появлению в программе типов наподобие uint32_t, хотя все библиотечные функции как раз таки требуют аргументов типа int.
Этот факт никак нельзя отнести к преимуществам языка.

Целочисленные примитивы в Java, в отличии от C, имеют фиксированную разрядность. Таким образом, можно не заботиться о реальной разрядности машины, на которой выполняется Java-программа, а также о порядке байт («сетевой» или «интеловский»). Этот факт помогает реализовать принцип «написано однажды – выполняется везде».

Кроме этого, в Java все целочисленные примитивы – знаковые (в языке отсутствует ключевое слово unsigned). Это исключает трудности при совместном использовании знаковых и беззнаковых переменных в одном выражении, присущие языку C.

В завершение, порядок байт в многобайтных примитивах в Java фиксированный (младший байт по младшему адресу, Little-endian, обратный порядок).

К недостаткам реализации операций с примитивами в Java можно отнести тот факт, что здесь, как и в программе на C/C++, может произойти переполнение разрядной сетки, причем никаких исключений при этом не возбуждается:

int i1 = 2_147_483_640;
int i2 = 2_147_483_640;
int r = (i1 + i2); // r = -16

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

Наследование


Наследование является еще одним китом ООП, о котором вы наверняка слышали. Если ответить кратко на вопрос «зачем наследование вообще нужно», то ответом будет «повторное использование кода».

Допустим, вы программируете на C, и у вас есть хорошо написанный и отлаженный «класс» – структура и функции для ее обработки. Далее возникает необходимость создать подобный «класс», но с расширенной функциональностью, причем базовый «класс» все еще остается нужен. В случае языка C для решения такой задачи у вас есть единственный путь – композиция. Речь идет о создании новой расширенной структуры-»класса», которая должна содержать указатель на базовый «класс»-структуру:

struct Base {
    int field1;
    char *field2;
};

void baseMethod(struct Base *obj, int arg);

struct Extended {
    struct Base *base;
    int auxField;
};

void extendedMethod(struct Extended *obj, int arg) {
    baseMethod(obj->base, 123);
    /* ... */
}

Java как объектно-ориентированный язык позволяет расширять функциональность имеющихся классов с помощью механизма наследования:

// Базовый класс
class Base {
    protected int baseField;
    private int hidden;
    public void baseMethod() {
    }
}
// Производный класс - расширяет функциональность базового.
class Extended extends Base {
    public void extendedMethod() {
        // Полный доступ к public и protected полям и методам базового класса.
        baseField = 123;
        baseMethod();
        // ОШИБКА! Доступ к private полям запрещен!
        hidden = 123;
    }
}

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

Благодаря наследованию классы в Java выстраиваются в иерархическую структуру, каждый класс обязательно имеет одного и только одного «родителя» и может иметь сколько угодно «детей». В отличие от C++, класс в Java не может наследовать более чем от одного родителя (таким образом решается проблема «ромбовидного наследования»).

При наследовании производный класс получает в свое расположение все public и protected поля и методы своего базового класса, а также базового класса своего базового класса и так далее вверх по иерархии наследования.

На вершине иерархии наследования находится общий прародитель всех Java классов – класс Object, единственный, кто не имеет родителя.

Динамическая идентификация типа


Одним из ключевых моментов языка Java является поддержка динамической идентификации типов (RTTI). Если говорить простыми словами, то RTTI позволяет подставлять объект производного класс там, где требуется ссылка на базовый:

// Ссылка на базовый класс
Base link;

// Ссылке на базовый класс присвоить объект производного класса
link = new Extended();

Имея ссылку во время выполнения программы, можно определить истинный тип объекта, на который данная ссылка ссылается – с помощью оператора instanceof:

if (link instanceof Base) {
    // false
} else if (link instanceof Extended) {
    // true
}

Переопределение методов


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

struct Object {
    // Указатель на функцию.
    void (*process)(struct Object *);
    int data;
};

void divideByTwo(struct Object *obj) {
    obj->data = obj->data / 2;
}

void square(struct Object *obj) {
    obj->data = obj->data * obj->data;
}
struct Object obj;
obj.data = 123;

obj.process = divideByTwo;
obj.process(&obj);  // 123 / 2 = 61

obj.process = square;
obj.process(&obj);  // 61 * 61 = 3721

В Java, как и в других языках ООП, переопределение (overriding) методов неразрывно связано с наследованием. Производный класс получает доступ к public- и protected-методам базового класса. Кроме того, что он может их вызывать, можно изменить поведение одного из методов базового класса, не меняя при этом его сигнатуру. Для этого достаточно определить в производном классе метод с точно такой же сигнатурой:

// Производный класс - расширяет функциональность базового.
class Extended extends Base {
    // Переопределенный метод.
    public void method() { /* ... */ }
    
    // Этот же метод не переопределен!
    // Eго сигнатура отличается от метода базового класса.
    // Это самостоятельный метод производного класса.
    public void method(int i) { /* ... */ }
}

Очень важно, чтобы сигнатура (имя метода, возвращаемое значение, аргументы) с точностью совпадали. Если имя метода совпадает, а аргументы отличаются, то происходит перегрузка (overloading) метода, подробнее о которой ниже.

Полиморфизм


Как инкапсуляция и наследование, третий кит ООП – полиморфизм – также имеет в некотором роде аналог в процедурно-ориентированном языке C.

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

 /* Идентификаторы классов */
enum Ids {
    ID_A, ID_B
};

struct ClassA {
    int id;
    /* ... */
}
void aInit(ClassA obj) {
    obj->id = ID_A;
}

struct ClassB {
    int id;
    /* ... */
}
void bInit(ClassB obj) {
    obj->id = ID_B;
}


/* klass - указатель на ClassA, ClassB, ... */
void commonFunc(void *klass) {
    /* Получить идентификатор */
    int id = (int *)klass;
    switch (id) {
    case ID_A:
        ClassA *obj = (ClassA *) klass;
        /* ... */
        break;
    case ID_B:
        ClassB *obj = (ClassB *) klass;
        /* ... */
        break;
    }
    /* ... */
}

Решение выглядит громоздко, но цель достигнута – универсальная функция commonFunc() принимает в качестве аргумента «объект» любого «класса». Обязательное условие – «класс»-структура в первом поле должна содержать идентификатор, по которому определяется действительный «класс» объекта. Такое решение возможно благодаря использованию аргумента с типом «void *». Однако такой функции можно передать указатель любого типа, например, «int *». Ошибок компиляции это не вызовет, но во время выполнения программа будет вести себя непредсказуемо.

Теперь рассмотрим, как полиморфизм выглядит в Java (впрочем, как и в любом другом языке ООП). Пусть у нас есть множество классов, которые должны однотипно обрабатываться некоторым методом. В отличие от решения для языка С, представленного выше, этот полиморфный метод ОБЯЗАН входить в состав всех классов данного множества, и все его версии ОБЯЗАНЫ иметь одинаковую сигнатуру.

class A {
    public void method() {/* ... */}
}
class B {
    public void method() {/* ... */}
}
class C {
    public void method() {/* ... */}
}

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

void executor(_set_of_class_ klass) {
    klass.method();
}

То есть метод executor(), который может быть где угодно в программе, должен «уметь» работать с любым классом из множества (A, B или C). Надо каким-то образом «сказать» компилятору, что _set_of_class_ обозначает наше множество классов. Здесь и пригождается наследование – необходимо сделать все классы из множества производными некоторого базового класса, который будет содержать полиморфный метод:

abstract class Base {
    abstract public void method();
}
class A extends Base {
    public void method() {/* ... */}
}
class B extends Base {
    public void method() {/* ... */}
}
class C extends Base {
    public void method() {/* ... */}
}
Тогда метод executor() будет выглядеть так:

void executor(Base klass) {
    klass.method();
}

И теперь ему можно передавать в качестве аргумента любой класс, который является наследником Base (благодаря динамической идентификации типа):

executor(new A());
executor(new B());
executor(new C());

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

Ключевое слово abstract позволяет исключить тело метода (сделать его абстрактным, выражаясь терминами ООП). Фактически мы говорим компилятору, что этот метод должен быть обязательно переопределен в классах наследниках. Если это не так, возникает ошибка компиляции. Класс, содержащий хотя бы один абстрактный метод, также называют абстрактным. Компилятор требует помечать такие классы также ключевым словом abstract.

Структура проекта на Java


В Java все файлы с исходным кодом имеют расширение *.java. Отсутствуют как заголовочные файлы *.h, так и прототипы функций или классов. Каждый файл с исходным кодом Java должен содержать хотя бы один класс. Имя класса принято записывать, начиная с заглавной буквы.

Несколько файлов с исходным кодом могут объединяться в пакет (package). Для этого должны быть выполнены следующие условия:
  1. Файлы с исходным кодом должны находиться в одной директории в файловой системе.
  2. Имя этой директории должно совпадать с именем пакета.
  3. В начале каждого файла с исходным кодом должен быть указан пакет, к которому этот файл относится, например:

package com.company.pkg;

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

Рекомендуется также задавать имена пакетов в нижнем регистре. Так их можно легко отличить от имен классов.

Сокрытие реализации


Еще один аспект инкапсуляции – разделение интерфейса и реализации. Если интерфейс доступен для внешних частей программы (внешних по отношению к модулю или классу), то реализация скрыта. В литературе часто проводят аналогию с черным ящиком, когда снаружи «не видна» внутренняя реализация, но «видно» то, что подается на вход ящика и что он выдает на выходе.

В языке C сокрытие реализации выполняют внутри модуля, помечая функции, которые не должны быть видны извне, ключевым словом static. Прототипы функций, которые составляют интерфейс модуля, выносятся в заголовочный файл. Под модулем в языке C понимается пара: файл с исходным кодом с расширением *.c и заголовочный с расширением *.h.

В Java также есть ключевое слово static, но оно не влияет на «видимость» метода или поля извне. Для управления «видимостью» предназначены 3 модификатора доступа: private, protected, public.

Поля и методы класса, помеченные как private, доступны только внутри его самого. Поля и методы protected доступны также наследникам класса. Модификатор public означает, что помеченный элемент доступен извне класса, то есть является частью интерфейса. Также возможно отсутствие модификатора, в этом случае доступ к элементу класса ограничен пакетом, в котором данный класс находится.

Рекомендуют в процессе написания класса изначально помечать все поля класса как private и расширять права доступа по мере возникновения необходимости.

Перегрузка методов


Одной из раздражающих черт стандартной библиотеки C является наличие целого зоопарка функций, выполняющих по сути одно и тоже, но различающихся типом аргумента, например: fabs(), fabsf(), fabsl() – функции для получения абсолютного значения для double, float и long double типов соответственно.

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

В Java в отличии от C++ нельзя перегружать операторы. Исключение составляют операторы «+» и «+=», которые изначально перегружены для строк String.

Символы и строки в Java


В языке C приходится работать с нуль-терминальными строками, представленными указателями на первый символ:

char *str;      // строка ASCII символов
wchar_t *strw;  // строка из "широких" символов

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

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

В Java примитивный тип char (а также «обертка» Character, об «обертках» – ниже) представляют один символ согласно стандарту Unicode. Используется кодировка UTF-16, соответственно один символ занимает в памяти 2 байта, что позволяет закодировать практически все символы используемых в настоящее время языков.

Символы можно задавать по их Unicode:

char ch1 = '\u20BD';

Если Unicode символа превышает максимальный 216 для типа char, то такой символ должен быть представлен типом int. В строке же он будет занимать 2 символа по 16 бит, но опять-таки символы с кодом, превышающем 216, используются крайне редко.

Строки Java реализованы встроенным классом String и хранят 16-битные символы char. В классе String собрано все или почти все, что может потребоваться для работы со строками. Здесь не надо думать о том, что строка должна обязательно заканчиваться нулем, здесь невозможно незаметно «затереть» этот нулевой завершающий символ или обратиться к памяти за пределы строки. И вообще, работая со строками в Java, программист не задумывается о том, в каком виде строка хранится в памяти.

Как говорилось выше, Java не допускает перегрузки операторов (как в С++), однако класс String является исключением – только для него изначально перегружены операторы слияния строк «+» и «+=».

String str1 = "Hello, " + "World!";
String str2 = "Hello, ";
str2 += "World!";

Примечательно, что строки в Java неизменны – будучи один раз созданы, они не допускают своего изменения. Когда мы пытаемся поменять строку, например, так:

String str = "Hello, World!";
str.toUpperCase();
System.out.println(str);  // Будет выведено "Hello, World!"

Tо исходная строка на самом деле не меняется. Вместо этого создается измененная копия исходной строки, которая в свою очередь так же является неизменной:

String str = "Hello, World!";
String str2 = str.toUpperCase();
System.out.println(str2);  // Будет выведено "HELLO, WORLD!"

Таким образом каждое изменение строки в реальности оборачивается созданием нового объекта (на самом деле в случаях слияния строк компилятор может оптимизировать код и использовать класс StringBuilder, о котором будет рассказано позже).

Бывает, что в программе необходимо часто изменять одну и ту же строку. В таких случаях в целях оптимизации быстродействия программы и потребления памяти можно предотвратить создание новых объектов-строк. Для этих целей следует использовать класс StringBuilder:

String sourceString = "Hello, World!";
StringBuilder builder = new StringBuilder(sourceString);
builder.setCharAt(4, '0');
builder.setCharAt(8, '0');
builder.append("!!");
String changedString = builder.toString();
System.out.println(changedString);  // Будет выведено "Hell0, W0rld!!!"

Отдельно стоит сказать о сравнении строк. Типичная ошибка начинающего Java-программиста, это сравнение строк с помощью оператора «==»:

// Если пользователь ввел "Yes"
// ОШИБКА!
if (usersInput == "Yes") {
    // Если строки равны
}

Такой код формально не содержит ошибок на стадии компиляции или ошибок времени выполнения, но работает он иначе, чем это можно было бы ожидать. Так как все объекты и строки, в том числе в Java, представлены ссылками, то сравнение оператором «==» дает сравнение ссылок, а не значений объектов. То есть результат будет true только если 2 ссылки действительно ссылаются на одну и ту же строку. Если же строки – разные объекты в памяти, и необходимо сравнить их содержимое, то надо использовать метод equals():

if (usersInput.equals("Yes")) {
    // Строки ДЕЙСТВИТЕЛЬНО равны
}

Самое удивительное, что в некоторых случаях сравнение с помощью оператора «==» работает правильно:

String someString = "abc", anotherString = "abc";
// Будет выведено "true":
System.out.println(someString == anotherString);

Так происходит из-за того, что в действительности ссылки someString и anotherString указывают на один и тот же объект в памяти. Компилятор помещает одинаковые строковые литералы в пул строк – происходит так называемое интернирование. Тогда каждый раз, когда в программе появляется такой же строковый литерал, используется ссылка на строку из пула. Интернирование строк как раз-таки и возможно благодаря свойству неизменности строк.

Хотя сравнение содержимого строк допускается только методом equals(), в Java есть возможность корректно использовать строки в switch-case конструкциях (начиная с версии Java 7):

String str = new String();
// ...
switch (str) {
    case "string_value_1":
        // ...
        break;
    case "string_value_2":
        // ...
        break;
}

Любопытно, что любой объект Java можно преобразовать в строку. Соответствующий метод toString() определен в базовом для всех классов классе Object.

Подход к обработке ошибок


Программируя на C, вы могли встречать следующий подход к обработке ошибок. Каждая функция какой-либо библиотеки возвращает тип int. Если функция выполнена успешно, то этот результат равен 0. Если же результат отличен от нуля – это свидетельствует об ошибке. Чаще всего код ошибки передают через возвращаемое функцией значение. Так как функция может вернуть лишь одно значение, и оно уже занято кодом ошибки, то действительный результат функции приходится возвращать через аргумент в виде указателя, например, так:

int function(struct Data **result, const char *arg) {
    int errorCode;
    /* ... */
    return errorCode;
}

Кстати, это один из случаев, когда в программе на C возникает необходимость в использовании указателя на указатель.

Иногда используют другой подход. Функция возвращает не код ошибки, а непосредственно результат своего выполнения, обычно в виде указателя. Ошибочная ситуация индицируется при этом нулевым указателем. Тогда библиотека обычно содержит отдельную функцию, которая возвращает код последней ошибки:

struct Data* function(const char *arg);
int getLastError();

Так или иначе, при программировании на C код, выполняющий «полезную» работу, и код, отвечающий за обработку ошибок, перемежает друг друга, что явно не делает программу легко читаемой.

В Java при желании можно использовать подходы, описанные выше, однако здесь можно применить совершенно иной способ работы с ошибками – обработка исключений (впрочем, как и в C++). Преимущество обработки исключений состоит в том, что в этом случае «полезный» код и код, отвечающий за обработку ошибок и нештатных ситуаций, логически отделены друг от друга.

Достигается это с помощью конструкций try-catch: в секции try размещается «полезный» код, а в секции catch – код обработки ошибок.

// При открытии файла может возникнуть ошибка
try (FileReader reader = new FileReader("path\\to\\file.txt")) {
    // При чтении файла - также возможна ошибка.
    while (reader.read() != -1){
        // ...
    }
} catch (IOException ex) {
    // Все ошибки обрабатываются здесь
}

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

public void func() throws Exception {
    // ...
}

Теперь вызов данного метода должен обязательно быть обрамлен в блок try-catch, или вызывающий метод также должен быть помечен, что он может вызывать это исключение.

Отсутствие препроцессора


Как бы ни был удобен знакомый C/C++-программистам препроцессор, в языке Java он отсутствует. Разработчики Java вероятно решили, что он используется только для обеспечения переносимости программ, а так как Java выполняется везде (почти) то и препроцессор в ней не нужен.

Компенсировать отсутствие препроцессора можно использованием статического поля-флага и проверять его значение в программе, где это необходимо.

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

Массив – это тоже объект


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

int array[5];
array[6] = 666;

Скорее всего программа продолжит выполнение, но значение переменной, которая располагалась следом за массивом array в примере выше, окажется искаженным. Отладка подобного рода ошибки может оказаться не простым делом.

В языке Java программист защищен от подобного рода труднодиагностируемых ошибок. При попытке выйти за границы массива возбуждается исключение ArrayIndexOutOfBoundsException. Если не был запрограммирован перехват исключения с помощью конструкции try-catch, программа аварийно завершается, а в стандартный поток ошибок отправляется соответствующее сообщение с указанием файла с исходным кодом и номера строки, где произошел выход за границы массива. То есть диагностика подобных ошибок становится тривиальным делом.

Такое поведение Java-программы стало возможным благодаря тому, что массив в Java представлен объектом. Массив Java не может менять размер, его размер жестко задается в момент выделения памяти. Во время выполнения же получить размер массива проще простого:

int[] array = new int[10];
int arraySize = array.length; // 10

Если говорить о многомерных массивах, то по сравнению с языком C в Java открывается интересная возможность организовать «лестничные» массивы. Для случая двумерного массива размер каждой отдельной строки может отличаться от остальных:

int[][] array = new int[10][];
for (int i = 0; i < array.length; i++) {
    array[i] = new int[i + 1];
}

Как и в C, элементы массива располагаются в памяти один за другим, поэтому доступ к массиву считается самым эффективным. Если же требуются выполнять операции вставки/удаления элементов, или создавать более сложные структуры данных, то необходимо использовать коллекции, такие как множество (Set), список (List), карта (Map).

За отсутствием указателей и невозможностью инкрементировать ссылки доступ к элементам массива возможен с помощью индексов.

Коллекции


Зачастую функциональности массивов оказывается недостаточно – тогда необходимо использовать динамические структуры данных. Так как стандартная библиотека C не содержит готовой реализации динамических структур данных, то приходится пользоваться реализацией в исходных кодах или в виде библиотек.

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

Списки – динамические массивы – позволяют добавлять/удалять элементы. Множества не обеспечивают порядка добавленных элементов, но гарантируют отсутствие дубликатов элементов. Карты или ассоциативные массивы оперируют парами «ключ – значение», причем значение ключа уникально – в карте не может быть 2 пары с одинаковыми ключами.

Для списков, множеств и карт существует множество реализаций, каждая из которых оптимизирована для определенной операции. Например, списки реализованы классами ArrayList и LinkedList, причем ArrayList обеспечивает лучшую производительность при доступе к произвольному элементу, а LinkedList – более эффективен при вставке/удалении элементов в середину списка.

В коллекциях могут храниться только полноценные Java-объекты (фактически – ссылки на объекты), поэтому создать непосредственно коллекцию примитивов (int, char, byte и др.) нельзя. В этом случае следует использовать соответствующие классы-«обертки»:
Примитив Класс-«обертка»
byte Byte
short Short
char Character
int Integer
long Long
float Float
double Double
boolean Boolean

К счастью, программируя на Java, нет необходимости следить за точным совпадением примитивного типа и его «обертки». Если метод получает аргумент, например, типа Integer, то ему можно передавать тип int. И наоборот, где требуется тип int, смело можно использовать Integer. Это стало возможным благодаря наличию в Java встроенного механизма упаковки/распаковки примитивов.

Из неприятных моментов следует упомянуть, что стандартная библиотека Java содержит старые классы коллекций, которые были неудачно реализованы в первых версиях Java и которые не следует использовать в новых программах. Речь идет о классах Enumeration, Vector, Stack, Dictionary, Hashtable, Properties.

Обобщения


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

List<Integer> list = new ArrayList<Integer>();

Это позволяет компилятору отследить попытку добавления в такой список объекта иного типа, нежели указанный тип-параметр:

List<Integer> list = new ArrayList<Integer>();
// ОШИБКА КОМПИЛЯЦИИ!
list.add("First");

Очень важно, что во время выполнения программы тип-параметр стирается, и нет никакой разницы между, например, объектом класса
ArrayList<Integer>
и объектом класса
ArrayList<String>.
Как следствие нет возможности узнать тип элементов коллекции во время выполнения программы:

public boolean containsInteger(List list) {
    // ОШИБКА КОМПИЛЯЦИИ!
    if (list instanceof List<Integer>) {
        return true;
    }
    return false;
}

Частичным решением может быть следующий подход: брать первый элемент коллекции и определять его тип:

public boolean containsInteger(List list) {
    if (!list.isEmpty() && list.get(0) instanceof Integer) {
        return true;
    }
    return false;
}

Но такой подход не сработает, если список пуст.

В этом плане обобщения Java значительно уступают обобщениям C++. Обобщения Java фактически служат для «отсечения» части потенциальных ошибок на стадии компиляции.

Перебор всех элементов массива или коллекции


Программируя на С часто приходится перебирать все элементы массива:

for (int i = 0; i < SIZE; i++) {
    /* ... */
}

Ошибиться здесь проще простого, достаточно указать неверный размер массива SIZE или поставить «<=» вместо «<».

В Java помимо «обычной» формы оператора for существует форма для перебора всех элементов массива или коллекции (в других языках часто называемая foreach):

List<Integer> list = new ArrayList<>();
// ...
for (Integer i : list) {
    // ...
}

Здесь мы гарантировано переберем все элементы списка, исключены ошибки, присущие «обычной» форме оператора for.

Коллекции разнородных элементов


Так как все объекты наследуются от коренного Object, то в Java есть интересная возможность создавать списки с различными фактическими типами элементов:

List list = new ArrayList<>();
list.add(new String("First"));
list.add(new Integer(2));
list.add(new Double(3.0));
Узнать фактический тип элементов списка можно используя оператор instanceof:
for (Object o : list) {
    if (o instanceof String) {
        // ...
    } else if (o instanceof Integer) {
        // ...
    } else if (o instanceof Double) {
        // ...
    }
}

Перечисления


Сравнивая C/C++ и Java невозможно не заметить, насколько функциональнее в Java реализованы перечисления. Здесь перечисление – это полноценный класс, а элементы перечисления – объекты этого класса. Это позволяет одному элементу перечисления задать в соответствие несколько полей любого типа:

enum Colors {
    // Объявление каждого элемента перечисления - это вызов конструктора.
    RED   ((byte)0xFF, (byte)0x00, (byte)0x00),
    GREEN ((byte)0x00, (byte)0xFF, (byte)0x00),
    BLUE  ((byte)0x00, (byte)0x00, (byte)0xFF),
    WHITE ((byte)0xFF, (byte)0xFF, (byte)0xFF),
    BLACK ((byte)0x00, (byte)0x00, (byte)0x00);
    // Поля перечисления.
    private byte r, g, b;
    // Приватный конструктор.
    private Colors(byte r, byte g, byte b) {
        this.r = r;
        this.g = g;
        this.b = b;
    }
    // Метод перечисления.
    public double getLuma() {
        return 0.2126 * r + 0.7152 * g + 0.0722 * b;
    }
}

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

Штатно присутствует возможность получить строковое представление элемента перечисления, порядковый номер, а также массив всех элементов:

Colors color = Colors.BLACK;
String str = color.toString();      // "BLACK"
int i = color.ordinal();            // 4
Colors[] array = Colors.values();   // [RED, GREEN, BLUE, WHITE, BLACK]

И наоборот – по строковому представлению можно получить элемент перечисления, а также вызывать его методы:

Colors red = Colors.valueOf("RED"); // Colors.RED
Double redLuma = red.getLuma();     // 0.2126 * 255

Естественно перечисления можно использовать в конструкциях switch-case.

Выводы


Безусловно, языки C и Java предназначены для решения совершенно разных задач. Но, если все-таки сравнить процесс разработки ПО на этих двух языках, то, по субъективным впечатлениям автора, язык Java значительно превосходит C по удобству и скорости написания программ. Немалую роль в обеспечении удобства играет среда разработки (IDE). Автор работал с IDE IntelliJ IDEA. Программируя на Java, не приходится постоянно «бояться» допустить ошибку – зачастую среда разработки подскажет, что надо исправить, а иногда сделает это за вас. Если же возникла ошибка времени выполнения, то в логе всегда указан тип ошибки и место ее возникновения в исходном коде – борьба с такими ошибками становится тривиальным делом. С-программисту для перехода на Java не надо прилагать нечеловеческих усилий, и все благодаря тому, что синтаксис языка изменился незначительно.

Если этот опыт будет интересен читателям, в следующей статье мы расскажем об опыте использования механизма JNI (запуск нативного C/C++-кода из Java-приложения). Механизм JNI незаменим, когда требуется управлять разрешением экрана, Bluetooth-модулем и в других случаях, когда возможностей сервисов и менеджеров Android оказывается недостаточно.

Комментарии (14)


  1. dev96
    31.05.2018 13:37

    Для улучшения читаемости программ в Java добавлена интересная возможность разделять разряды длинных чисел символом подчеркивания:

    int value1 =  1_500_000;
    long value2 = 0xAA_BB_CC_DD;


    В C++ также разделяются разряды:
    int value1 = 1'500'000;


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


    1. Free_ze
      31.05.2018 14:07

      Как и range-based for, библиотека коллекций, std::string, тип ссылок, по части управления ресурсами — такая важная вещь, как RAII.


      1. dev96
        31.05.2018 15:08

        еще добавляем семантику перемещения, возможности метапрограммирования, constexpr и прочие плюшки…


    1. dev96
      31.05.2018 15:26

      в С++, кстати, даже используя возврат bool/int, в качестве флага ошибки, вместо исключений, можно делать это изящно без передачи «выходного» аргумента, с возвратом флага и значения, через структурированные привязки:

      auto [result, data] = obj.CalculateSomething();

      Также есть std::optional…
      И в С++ препроцессор — это зло, особенно огорчает его наплевательское отношение к namespace. Для условной компиляции, есть if constexpr (compile-time) и шаблоны. Для макро-функций, есть constexpr выражения, которые не подставляют тупо строку, а вычисляют выражения, плюс делают это с учётом типов.


  1. aleksandy
    31.05.2018 13:52

    только для выхода из вложенных циклов

    Не только циклов, но и произвольных блоков кода.

    один способ передачи объекта в метод – по ссылке.

    Нет. В Java все аргументы метода передаются по значению. Просто для объектных типов значением является ссылка.

    сигнатура (имя метода, возвращаемое значение, аргументы) с точностью совпадали

    Нет. Возвращаемое значение не входит в сигнатуру метода.
    Код, демонстрирующий утверждение
    class A {
        A method() {
            throw new UnsupportedOperationException("not implemented");
        }
    }
    class B extends A {
        @Override B method() {
            return this;
        }
    }


    1. Showvars
      31.05.2018 15:36

      Этой возможностью лучше не пользоваться, т.к. это сахар, на деле, switch делается по хешкоду, который может иметь коллизии.

      Не совсем. Сравнение делается вызовом String.equals, а этот метод сравнивает строки посимвольно. Допускается, что в байт-коде может быть все по другому, но поведение должно быть именно таким.


      1. Perlovich
        31.05.2018 20:59

        В поисках правды я обратился к Oracle документации по Java: docs.oracle.com/javase/specs/index.html

        Спецификация языка Java согласна с Вами и утверждает, что строки в switch сравниваются через equals: docs.oracle.com/javase/specs/jls/se10/html/jls-14.html#jls-14.11.

        If one of the case constants is equal to the value of the expression, then we say that the case label matches. Equality is defined in terms of the == operator (§15.21) unless the value of the expression is a String, in which case equality is defined in terms of the equals method of class String.


        Но в JVM спецификации написано следующее — docs.oracle.com/javase/specs/jvms/se10/html/jvms-3.html#jvms-3.10:

        The Java Virtual Machine's tableswitch and lookupswitch instructions operate only on int data. Because operations on byte, char, or short values are internally promoted to int, a switch whose expression evaluates to one of those types is compiled as though it evaluated to type int.


        Строки прямо не упомянуты. Но все остальные типы приводятся к int. Что делает версию с проверкой строки по хэшкоду весьма правдоподобной.

        Не получив четкого ответа из доков, начал компилить и декомпилить. У меня HotSpot JVM 1.8. При декомпиляции switch по String видно, что используется и hashcode и equals:

        switch(name.hashCode()) {
                case 3556498:
                    if (name.equals("test")) {
                        // здесь код
                    }
        


    1. ExplosiveZ
      01.06.2018 08:11

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

      docs.oracle.com/javase/specs/jls/se8/html/jls-8.html#jls-8.4.2


  1. potan
    31.05.2018 19:53
    +1

    Java — очень старый язык. Сейчас доступны языки значительно более высокого уровня. При этом некоторые (Rust, ATS) по производительности и возможности работы с аппаратурой не уступают C/C++. Интересно было бы сравнить с ними.


    1. Whuthering
      01.06.2018 11:20
      +1

      Rust — язык «значительно более высокого уровня», чем Java?
      Какое чудесное заявление, однако.


      1. potan
        01.06.2018 11:52

        В Rust значительно больше условий проверяется на этапе компиляции. Есть классы типов — очень высокоуровневый мезанизм. Есть алгебраические типы и pattern matching. Различаются беззнаковые и знаковые числа. Есть синтаксические макросы.
        Все это означает более высокий уровень абстракции, на котором может работать программист.


        1. Wyrd
          01.06.2018 22:59

          А а Java есть reflection. Ну и тот же паттерн-матчинг через какое-то время, я думаю, появится (как появился в c#). Rust просто другой язык. Не выше и не ниже уровнем: просто другой и для других целей сделанный.


          1. potan
            02.06.2018 01:02
            +1

            Reflection — скорее низкоуровревая конструкция, перекладывающая на программиста работу компилятора. По этому поводу я всегда вспоминаю главу «Правильное и неправильное использование RTTI» в книге Страуструппа, в которой приведено несколько примеров неправильного использования и ни одного правильного. В Java reflection нужен для инструментальных средств (но разработчики языка вполне могли бы предложить специализированные решения дли того же позднего связавания), реализации динамических языков (очень специфическая область, что бы под нее затачивать язык) и для компенсации недоработок в дизайне (отсутствие pattern matching).


  1. windgrace
    01.06.2018 11:49

    В завершение, порядок байт в многобайтных примитивах в Java фиксированный (младший байт по младшему адресу, Little-endian, обратный порядок).


    А разве не BigEndian?
        public static void main(String[] args) {
            long value = 1;
            System.out.println(Longs.toByteArray(value)[0]); // выводит "0"
        }