Thinking in Java, 2nd edition, Revision 11

©2000 by Bruce Eckel

[ Предыдущая глава ] [ Оглавление ] [ Содержание ] [ Индекс ] [ Следующая глава ]

4: Инициализация и очистка

В процессе компьютерной революции, “не безопасное” программирование стало главной виной его удорожания.

Двумя основными проблемами безопасности являются инициализация и очистка. Многие ошибки в C возникали тогда, когда программист забывал инициализировать переменную. Это особенно верно для библиотек, когда пользователь не знает о том, как инициализировать компонент библиотеки или о том, что он должен это сделать. Очистка - это особая проблема, потому что легче забыть об элементе, когда вы уже закончили работать с ним, так как он больше не притягивает ваше внимание. Таким образом, ресурсы, используемые элементом, остаются, и вы можете легко прийти к завершению программы из-за нехватки ресурсов (чаще всего, это память).

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

Гарантированная инициализация при использовании конструктора

Вы можете выбрать подход создания метода, называемого initialize( ) для каждого созданного вами класса. Имя является подсказкой к тому, что он должен быть вызван перед использованием объекта. К сожалению, это означает, что пользователь должен помнить о вызове метода. В Java разработчик классов может гарантировать инициализацию каждого объекта, обеспечив специальный метод, называемый конструктором. Если класс имеет конструктор, Java автоматически вызывает конструктор, когда создается объект, прежде чем пользователь сможет взять его в руки. Поэтому инициализация гарантируется.

Следующая сложность состоит в названии метода. Есть две проблемы. Первая заключается в том, что любое имя, которое вы используете, может совпасть с именем, которое вы захотите использовать в качестве члена класса. А вторая заключается в том, что, так как компилятор отвечает за вызов конструктора, то он всегда должен знать, какой метод вызывать. Решение, принятое в C++ кажется простым и логичным, так что оно также используется в Java: имя конструктора совпадает с именем класса. Это имеет смысл, так как такой метод будет вызван автоматически при инициализации.

Вот пример класса с конструктором:

//: c04:SimpleConstructor.java
// Демонстрация простого конструктора.

class Rock {
  Rock() { // это конструктор
    System.out.println("Creating Rock");
  }
}

public class SimpleConstructor {
  public static void main(String[] args) {
    for(int i = 0; i < 10; i++)
      new Rock();
  }
} ///:~

Теперь, когда объект создан:

new Rock();

место хранения зарезервировано и конструктор вызван. Это гарантирует то, что объект будет правильно инициализирован прежде, чем вы получите его.

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

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

//: c04:SimpleConstructor2.java
// конструктор может иметь аргументы.

class Rock2 {
  Rock2(int i) {
    System.out.println(
      "Creating Rock number " + i);
  }
}

public class SimpleConstructor2 {
  public static void main(String[] args) {
    for(int i = 0; i < 10; i++)
      new Rock2(i);
  }
} ///:~

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

Tree t = new Tree(12);  // 12-ти футовое дерево

Если Tree(int) ваш единственный конструктор, то компилятор не позволит вам создать объект Tree другим способом.

Конструктор снимает большой класс проблем и делает код легче для чтения. В приведенном выше фрагменте кода, например, вы не видите явного вызова некоторого метода initialize( ), который концептуально отделен от определения. В Java определение и инициализация является объединенной концепцией — вы не можете получить одно без другого.

Конструктор является необычным типом метода, поскольку он не имеет возвращаемого значения. Это заметно отличается от возвращаемого значения типа void, когда метод не возвращает ничего, но вы все еще имеете возможность вернуть что-то иное. Конструктор не возвращает ничего, и вы не имеете вариантов. Если бы он имел возвращаемое значение, и если бы вы могли выбирать свое собственное, компилятор не знал бы, что делать с этим возвращаемым значением.

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

Одна из главных особенностей в любом языке программирования - это использование имен. Когда вы создаете объект, вы даете имя области хранения. Метод - это имя действия. При использовании имен для описания вашей системы вы создаете программу, которую людям легче понять и изменить. Это очень похоже на написание прозаического произведения — целью является взаимодействие с вашими читателями.

Вы обращаетесь ко всем объектам и методам по имени. Хороший подбор имен облегчает понимание кода для вас и ваших читателей.

Проблемы возникают, когда происходит перекладывание нюансов концепции с человеческого языка на язык программирования. Часто одни и те же слова выражают несколько различных смыслов это называется перегрузкой. Это полезно, особенно когда имеете дело с тривиальными отличиями. Вы говорите “стирать (мыть) рубашку”, “мыть машину” и “мыть собаку”. Было бы глупо ограничиваться фразами, типа “рубашкоМойка рубашки”, “машиноМойка машины” и “собакоМойка собаки”, так как слушателю события нет необходимости делать различия при выполнении действия. Большинство человеческих языков являются многословными, так что даже если вы пропустите несколько слов, вы все равно поймете смысл. Мы не нуждаемся в уникальных идентификаторах — мы можем вывести смысл из контекста.

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

В Java (и C++) один из факторов вынуждает использовать перегрузку методов: конструктор. Поскольку имя конструктора является предопределенным именем класса, то может быть только одно имя конструктора. Но что, если вы хотите создавать объект более чем одним способом? Например, предположим, вы строите класс, который может инициализировать себя стандартным способом или путем чтения информации из файла. Вам нужно два конструктора, один не принимает аргументов (конструктор по умолчанию, также называемый конструктором без аргументов), а другой принимает в качестве аргумента String, который является именем файла, из которого инициализируется объект. Ода они являются конструкторами, так что они должны иметь одно и то же имя — имя класса. Таким образом, перегрузка методов необходима для получения возможности использования одного и того же имени метода с разными типами аргументов. И хотя перегрузка методов необходима для конструкторов, она является общим соглашением и может использоваться для любого метода.

Вот пример, показывающий оба перегруженных конструктора и перегрузку обычного метода:

//: c04:Overloading.java
// Демонстрация перегрузки конструктора
// и обычного метода.
import java.util.*;

class Tree {
  int height;
  Tree() {
    prt("Planting a seedling");
    height = 0;
  }
  Tree(int i) {
    prt("Creating new Tree that is "
        + i + " feet tall");
    height = i;
  }
  void info() {
    prt("Tree is " + height
        + " feet tall");
  }
  void info(String s) {
    prt(s + ": Tree is "
        + height + " feet tall");
  }
  static void prt(String s) {
    System.out.println(s);
  }
}

public class Overloading {
  public static void main(String[] args) {
    for(int i = 0; i < 5; i++) {
      Tree t = new Tree(i);
      t.info();
      t.info("overloaded method");
    }
    // Перегруженный конструктор:
    new Tree();
  }
} ///:~

Объект Дерева(Tree) может быть создан либо рассадой, без аргументов, либо получен плановой посадкой в лесном хозяйстве по заданной высоте. Для поддержки этого есть два конструктора, один не принимает аргументов (мы называем конструктор, который не принимает аргументов, конструктором по умолчанию [27]), а другой принимает существующую высоту.

Вы так же можете захотеть вызвать метод info( ) более чем одним способом. Например, с аргументом String, если у вас есть желание напечатать дополнительное сообщение, и без него, если вам нечего сказать. Было бы странным давать два разных имени для того, что имеет одну и ту же концепцию. К счастью, перегрузка методов позволяет вам использовать одно и то же имя в обоих случаях.

Как различать перегруженные методы

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

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

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

//: c04:OverloadingOrder.java
// Перегрузка, основывающаяся на
// порядке следования аргументов.

public class OverloadingOrder {
  static void print(String s, int i) {
    System.out.println(
      "String: " + s +
      ", int: " + i);
  }
  static void print(int i, String s) {
    System.out.println(
      "int: " + i +
      ", String: " + s);
  }
  public static void main(String[] args) {
    print("String first", 11);
    print(99, "Int first");
  }
} ///:~

Два метода print( ) имеют идентичные аргументы, но порядок их следования различается. Это дает возможность различать их.

Перегрузка с помощью примитивных типов

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

//: c04:PrimitiveOverloading.java
// Преобразование примитивных типов и перегрузка.

public class PrimitiveOverloading {
  // boolean не может конвертироваться автоматически
  static void prt(String s) { 
    System.out.println(s); 
  }

  void f1(char x) { prt("f1(char)"); }
  void f1(byte x) { prt("f1(byte)"); }
  void f1(short x) { prt("f1(short)"); }
  void f1(int x) { prt("f1(int)"); }
  void f1(long x) { prt("f1(long)"); }
  void f1(float x) { prt("f1(float)"); }
  void f1(double x) { prt("f1(double)"); }

  void f2(byte x) { prt("f2(byte)"); }
  void f2(short x) { prt("f2(short)"); }
  void f2(int x) { prt("f2(int)"); }
  void f2(long x) { prt("f2(long)"); }
  void f2(float x) { prt("f2(float)"); }
  void f2(double x) { prt("f2(double)"); }

  void f3(short x) { prt("f3(short)"); }
  void f3(int x) { prt("f3(int)"); }
  void f3(long x) { prt("f3(long)"); }
  void f3(float x) { prt("f3(float)"); }
  void f3(double x) { prt("f3(double)"); }

  void f4(int x) { prt("f4(int)"); }
  void f4(long x) { prt("f4(long)"); }
  void f4(float x) { prt("f4(float)"); }
  void f4(double x) { prt("f4(double)"); }

  void f5(long x) { prt("f5(long)"); }
  void f5(float x) { prt("f5(float)"); }
  void f5(double x) { prt("f5(double)"); }

  void f6(float x) { prt("f6(float)"); }
  void f6(double x) { prt("f6(double)"); }

  void f7(double x) { prt("f7(double)"); }

  void testConstVal() {
    prt("Testing with 5");
    f1(5);f2(5);f3(5);f4(5);f5(5);f6(5);f7(5);
  }
  void testChar() {
    char x = 'x';
    prt("char argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testByte() {
    byte x = 0;
    prt("byte argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testShort() {
    short x = 0;
    prt("short argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testInt() {
    int x = 0;
    prt("int argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testLong() {
    long x = 0;
    prt("long argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testFloat() {
    float x = 0;
    prt("float argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testDouble() {
    double x = 0;
    prt("double argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  public static void main(String[] args) {
    PrimitiveOverloading p = 
      new PrimitiveOverloading();
    p.testConstVal();
    p.testChar();
    p.testByte();
    p.testShort();
    p.testInt();
    p.testLong();
    p.testFloat();
    p.testDouble();
  }
} ///:~

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

Что произойдет, если ваш аргумент больше, чем аргумент, ожидаемый перегруженным методом? Модифицированная программа дает ответ на этот вопрос:

//: c04:Demotion.java
// Понижение примитивных типов и перегрузка.

public class Demotion {
  static void prt(String s) { 
    System.out.println(s); 
  }

  void f1(char x) { prt("f1(char)"); }
  void f1(byte x) { prt("f1(byte)"); }
  void f1(short x) { prt("f1(short)"); }
  void f1(int x) { prt("f1(int)"); }
  void f1(long x) { prt("f1(long)"); }
  void f1(float x) { prt("f1(float)"); }
  void f1(double x) { prt("f1(double)"); }

  void f2(char x) { prt("f2(char)"); }
  void f2(byte x) { prt("f2(byte)"); }
  void f2(short x) { prt("f2(short)"); }
  void f2(int x) { prt("f2(int)"); }
  void f2(long x) { prt("f2(long)"); }
  void f2(float x) { prt("f2(float)"); }

  void f3(char x) { prt("f3(char)"); }
  void f3(byte x) { prt("f3(byte)"); }
  void f3(short x) { prt("f3(short)"); }
  void f3(int x) { prt("f3(int)"); }
  void f3(long x) { prt("f3(long)"); }

  void f4(char x) { prt("f4(char)"); }
  void f4(byte x) { prt("f4(byte)"); }
  void f4(short x) { prt("f4(short)"); }
  void f4(int x) { prt("f4(int)"); }

  void f5(char x) { prt("f5(char)"); }
  void f5(byte x) { prt("f5(byte)"); }
  void f5(short x) { prt("f5(short)"); }

  void f6(char x) { prt("f6(char)"); }
  void f6(byte x) { prt("f6(byte)"); }

  void f7(char x) { prt("f7(char)"); }

  void testDouble() {
    double x = 0;
    prt("double argument:");
    f1(x);f2((float)x);f3((long)x);f4((int)x);
    f5((short)x);f6((byte)x);f7((char)x);
  }
  public static void main(String[] args) {
    Demotion p = new Demotion();
    p.testDouble();
  }
} ///:~

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

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

Перегрузка по возвращаемому значению

Это обычное удивление: “Почему только имена классов и список аргументов метода? Почему не делать различия между методами, основываясь на их возвращаемом значении?” Например, эти методы имеют одинаковое имя и список аргументов, но легко отличаются друг от друга:

void f() {}
int f() {}

Это хорошо работает, когда компилятор может недвусмысленно определить смысл из контекста, как в случае int x = f( ). Однако, вы можете вызвать метод и проигнорировать возвращаемое значение; это часто называется побочным действием вызова метода, так как вы не заботитесь о возвращаемом значении, а просто ждете других эффектов от вызова метода. Таким образом, если вы вызываете функция следующим образом:

f();

как Java может определить какой из методов f( ) должен быть вызван? И как другой человек мог бы прочесть приведенный код? Из-за возникновения проблем такого рода вы не можете использовать тип возвращаемого значения для различения перегруженных методов.

Конструктор по умолчанию

Как упоминалось ранее, конструктор по умолчанию является единственным конструктором без аргументов, который используется для создания объекта”. Если вы создаете класс, который не имеет конструкторов, компилятор автоматически создаст конструктор по умолчанию вместо вас. Например:

//: c04:DefaultConstructor.java

class Bird {
  int i;
}

public class DefaultConstructor {
  public static void main(String[] args) {
    Bird nc = new Bird(); // по умолчанию!
  }
} ///:~

Строка

new Bird();

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

class Bush {
  Bush(int i) {}
  Bush(double d) {}
}

Теперь, если вы скажете:

new Bush();

компилятор заявит, что он не может найти соответствующий конструктор. Это похоже на то, что когда вы не определяете ни одного конструктора, компилятор говорит: “Вы обязаны иметь какой-то конструктор, так что позвольте мне создать его за вас”. Но если вы написали конструктор, компилятор говорит: “Вы написали конструктор, так что вы знаете, что вы делаете; если вы не создали конструктор по умолчанию, это значит, что он вам не нужен”.

Ключевое слово this

Если вы имеете два объекта одного и того же типа, с именами a и b, вы можете задуматься, как вы можете вызвать метод f( ) для этих обоих объектов:

class Banana { void f(int i) { /* ... */ } }
Banana a = new Banana(), b = new Banana();
a.f(1);
b.f(2);

Если есть только один метод с именем f( ), как этот метод узнает, был ли он вызван объектом a или b?

Чтобы позволить вам писать код в последовательном объектно-ориентированном синтаксисе, в котором вы “посылаете сообщения объекту”, компилятор выполняет некоторую скрытую от вас работу. Секрет в первом аргументе, передаваемом методу f( ), и который отражает ссылку на объект, с которым происходит манипуляция. Так что эти два вызова метода, приведенные выше, становятся похожи на следующие вызовы:

Banana.f(a,1);
Banana.f(b,2);

Это внутренний формат и вы не можете записать эти выражения и дать компилятору доступ к ним, но это дает вам представление об идеи происходящего.

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

class Apricot {
  void pick() { /* ... */ }
  void pit() { pick(); /* ... */ }
}

Внутри pit( ), вы могли сказать this.pick( ), но в этом нет необходимости. Компилятор делает это за вас автоматически. Ключевое слово this используется только в тех особых случаях, в которых вам нужно явное использование ссылки на текущий объект. Например, оно часто используется в инструкции return, когда вы хотите вернуть ссылку на текущий объект:

//: c04:Leaf.java
// Простое использование ключевого слова "this".

public class Leaf {
  int i = 0;
  Leaf increment() {
    i++;
    return this;
  }
  void print() {
    System.out.println("i = " + i);
  }
  public static void main(String[] args) {
    Leaf x = new Leaf();
    x.increment().increment().increment().print();
  }
} ///:~

Поскольку increment( ) возвращает ссылку на текущий объект через ключевое слово this, множественные операции могут быть легко выполнены над тем же объектом.

Вызов конструктора из конструктора

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

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

//: c04:Flower.java
// Вызов конструкторов с использованием "this".

public class Flower {
  int petalCount = 0;
  String s = new String("null");
  Flower(int petals) {
    petalCount = petals;
    System.out.println(
      "Constructor w/ int arg only, petalCount= "
      + petalCount);
  }
  Flower(String ss) {
    System.out.println(
      "Constructor w/ String arg only, s=" + ss);
    s = ss;
  }
  Flower(String s, int petals) {
    this(petals);
//!    this(s); // Нельзя вызвать два!
    this.s = s; // Другое использование "this"
    System.out.println("String & int args");
  }
  Flower() {
    this("hi", 47);
    System.out.println(
      "default constructor (no args)");
  }
  void print() {
//!    this(11); // Не внутри - не конструктор!
    System.out.println(
      "petalCount = " + petalCount + " s = "+ s);
  }
  public static void main(String[] args) {
    Flower x = new Flower();
    x.print();
  }
} ///:~

Конструктор Flower(String s, int petals) показывает, что когда вы вызываете один конструктор, используя this, вы не можете вызвать второй. Кроме того, вызов конструктора должен быть первой вещью, которую вы делаете, или вы получите сообщение об ошибке.

Этот пример также показывает другой способ, которым вы можете использовать this. Так как имя аргумента s и имя члена-данного s совпадает, здесь может возникнуть некоторая двусмысленность. Чтобы разрешить ее, вы говорите this.s, чтобы указать на член-данное. Вы часто будите видеть эту форму использования в Java коде, и она используется в некоторых местах этой книги.

В print( ) вы можете видеть, что компилятор не позволяет вам вызывать конструктор изнутри любого метода, отличного от конструктора.

Смысл static

Имея в виду ключевое слово this, вы можете более полно понимать, что означает создание static метода. Это означает, что здесь нет this из обычного метода. Вы не можете вызвать не-static метод изнутри static метода [28] (хотя обратная ситуация возможна), но вы можете вызвать static метод класса без любого объекта. Фактически, это первичная задача, для чего нужны static методы. Это аналогично созданию глобальной функции (в C). Поскольку глобальные функции в Java не допустимы, помещение static методов внутрь класса позволяет получить доступ к другим static методам и static полям.

Некоторые люди утверждают, что static методы не являются объектно-ориентированными, так как они имеют семантику глобальных функций; с помощью static метода вы не посылаете сообщение объекту, та как здесь нет this. Это достаточно сильный аргумент, и если вы ловите себя на том, что вы используете очень много статических методов, вероятно, вы должны изменить свою стратегию. Однако static является практичным способом, и иногда вы действительно нуждаетесь в нем, так что вопрос о том, относится ли такой подход “истинным ООП”, оставим для теоретиков. На самом деле, даже Smalltalk имеет аналог среди своих “методов класса”.

Очистка: финализация и сборщик мусора

Программисты знают о важности инициализации, но часто забывают о важности очистки. Помимо всего, кому понадобится очищать значения, типа int? Но с библиотеками, просто “позволить идти своей дорогой” тем объектам, с которыми вы закончили работать не всегда безопасно. Конечно, Java имеет сборщик мусора для освобождения памяти объектов, которые более не используются. Теперь об очень редком случае. Предположим, что ваш объект зарезервировал “специальную” память, не используя new. Сборщик мусора знает только, как освобождать память, выделенную с помощью new, так что он не может освободить “специальную” память объектов. Для обработки этого случая Java обеспечивает метод, называемый finalize( ), который вы можете определить в своем классе. Вот как это должно работать. Когда сборщик мусора готов освободить хранилище, используемое вашим объектом, он сначала вызовет finalize( ), а только на следующем этапе сборки мусора он освободит память объекта. Так что, если вы выбрали использование finalize( ), это даст вам возможность выполнить некоторые важные для очистки операции во время сборки мусора.

Это потенциальная ловушка программистам, так как некоторые из них, особенно программисты на C++, могут ошибочно полагать, что finalize( ) аналогично деструкторам в C++, которые являются функциями, которые всегда вызываются при разрушении объекта. Но в этом заключается важное различие между C++ и Java, потому что в C++ объекты всегда разрушаются (в программах без ошибок), в то время как в Java объекты не всегда попадают под сборку мусора. Или, говоря другими словами:

Сборка мусора - это не разрушение.

Если вы запомните это, вы не встретите трудностей. Это означает, что если есть какие-то действия, которые необходимо выполнить прежде, чем вы закончите работать с объектом, вы должны выполнить эти действия самостоятельно. Java не имеет деструкторов или аналогичной концепции, так что вы должны создать обычный метод для выполнения этой очистки. Например, предположим, что в процессе создания вашего объекта он рисует себя на экране. Если вы явно не вызовите стирание этого изображения с экрана, оно может никогда не очистится. Если вы помещаете некоторое стирание внутри функции finalize( ), то если объект подвергнется сборке мусора, изображение сначала будет стерто с экрана, но если этого не произойдет, то изображение останется. Так что второе, что вы должны запомнить:

Ваши объекты могут не подвергнуться сборке мусора.

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

Для чего нужен finalize( )?

После всего изложенного выше вы можете предположить, что вы не должны использовать finalize( ) в целях очистки общего назначения. Насколько это хорошо?

Третье, что вы должны помнить:

Сборка мусора относится только к памяти.

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

Значит ли это, что если ваш объект содержит другие объекты, finalize( ) должен явно освободить эти объекты? Нет, сборщик мусора позаботится об освобождении памяти всех объектов, независимо от того, как были созданы объекты. Оказывается, что потребность в finalize( ) ограничивается особыми случаями, в которых объекты могут резервировать некоторое хранилище другим способом, отличным от создания объектов. Но, вы можете заметить, что все в Java - это объекты. Как же такое может быть?

Таким образом, finalize( ) занимает свое место, потому что существует возможность, что вы выполнили подобное C резервирование памяти, используя механизм, отличный от естественного для Java. Это может произойти, в основном, в родных методах, которые являются способом вызова не Java кода из Java. (Родные методы обсуждаются в Приложении B.) C и C++ являются теми языками, которые в настоящее время поддерживаются родными методам, но так как они могут вызывать подпрограммы других языков, вы можете, на самом деле, вызвать все, что угодно. Внутри не Java кода семейство функций malloc( ) из C может быть вызвано для резервирования хранилища, и до тех пор, пока вы не вызовите free( ), это хранилище не будет освобождено, что приводит к утечке памяти. Конечно, free( ) является функцией C и C++, так что вам необходимо вызывать ее в родном методе внутри вашего finalize( ).

После того, что вы прочли, вы, вероятно, пришли к мысли, что вам чаще всего не нужно использовать finalize( ). Вы правы; это не подходящее место для выполнения обычной очистки. Тогда где должна выполнятся обычная очистка?

Вы должны выполнять очистку

Для очистки объекта пользователь этого объекта должен вызвать метод очистки в том месте, где это не обходимо. Эти слова хорошо понятны, но это приводит к концепции деструктора из C++, что все объекты разрушаются. Или вернее, что все объекты должны разрушаться. Если объекты C++ создаются локально (т.е. в стеке, что невозможно в Java), то разрушение происходит в месте закрытия фигурной скобки того блока, в котором объект был создан. Если объект был создан с помощью new (как в Java), деструктор вызывается, когда программист вызовет оператор delete из C++ (который не существует в Java). Если программист C++ забудет вызвать delete, деструктор никогда не вызовется, и вы получите утечку памяти, в сочетании со всеми другими прелестями отсутствия очистки объектов. Этот род ошибок могут быть очень трудными в обнаружении.

В противоположность этому, Java не позволяет создание локальных объектов — вы всегда должны использовать new. Но в Java нет “delete” для выполнения освобождения объекта, так как сборщик мусора освобождает хранилище за вас. Так что, с точки зрения простоты, вы могли бы сказать, что по причине сборки мусора Java не имеет деструкторов. Однако в процессе чтения книги вы увидите, что присутствие сборщика мусора не снимает требования в существовании таких средств, как деструкторы. (И вы никогда не должны вызывать finalize( ) напрямую, так что это не подходящий путь для решения.) Если вы некоторый род очистки выполняет освобождение других ресурсов, отличных от хранилища, вы должны все-таки явно вызвать соответствующий метод Java, который является эквивалентом деструктора C++, без каких-либо соглашений.

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

//: c04:Garbage.java
// Демонстрация сборщика мусора
// и финализации

class Chair {
  static boolean gcrun = false;
  static boolean f = false;
  static int created = 0;
  static int finalized = 0;
  int i;
  Chair() {
    i = ++created;
    if(created == 47) 
      System.out.println("Created 47");
  }
  public void finalize() {
    if(!gcrun) {
      // Первый раз вызывается finalize():
      gcrun = true;
      System.out.println(
        "Beginning to finalize after " +
        created + " Chairs have been created");
    }
    if(i == 47) {
      System.out.println(
        "Finalizing Chair #47, " +
        "Setting flag to stop Chair creation");
      f = true;
    }
    finalized++;
    if(finalized >= created)
      System.out.println(
        "All " + finalized + " finalized");
  }
}

public class Garbage {
  public static void main(String[] args) {
    // До тех пор, пока флаг не установлен,
    // создаются Chairs и Strings:
    while(!Chair.f) {
      new Chair();
      new String("To take up space");
    }
    System.out.println(
      "After all Chairs have been created:\n" +
      "total created = " + Chair.created +
      ", total finalized = " + Chair.finalized);
    // Необязательные аргументы форсируют
    // сборку мусора и финализацию
    if(args.length > 0) {
      if(args[0].equals("gc") || 
         args[0].equals("all")) {
        System.out.println("gc():");
        System.gc();
      }
      if(args[0].equals("finalize") || 
         args[0].equals("all")) {
        System.out.println("runFinalization():");
        System.runFinalization();
      }
    }
    System.out.println("bye!");
  }
} ///:~

Приведенная выше программа создает множество объектов Chair, и в некоторой точке, после начала работы сборщика мусора, программа прекращает создание Chair. Так как сборщик мусора может запуститься в любой момент, вы не можете точно знать, когда он стартует, поэтому есть флаг, называемый gcrun для индикации того, произошел ли запуск сборщика мусора. Второй флаг f дает возможность объекту Chair сообщить в цикл main( ), что он должен остановить создание объектов. Оба эти флага устанавливаются в finalize( ), который вызывается при сборке мусора.

Две другие static переменные: created и finalized, следят за числом созданных Chair и за числом объектов, подвергшихся финализации сборщиком мусора. И, наконец, каждый Chair имеет свой собственный (не-static) int i, который следит за тем, какой порядковый номер имеет объект. Когда финилизируется Chair с номером 47, флаг устанавливается в true, чтобы инициировать остановку процесса создания Chair.

Все это происходит в цикле main( )

    while(!Chair.f) {
      new Chair();
      new String("To take up space");
    }

Вы можете удивиться, как этот цикл вообще может завершиться, так как внутри нет ничего, что изменяло бы значение Chair.f. Однако finalize( ), в конечном счете, сделает это, когда будет финализован объект номер 47.

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

Когда вы запускаете программу, вы передаете аргумент командной строки “gc,” “finalize,” или “all”. Аргумент “gc” приведет к вызову метода System.gc( ) (для форсирования работы сборщика мусора). Использование “finalize” приведет к вызову System.runFinalization( ), который, теоретически, является причиной того, что не финализированные объекты будут финализированы. А “all” станет причиной вызова обоих методов.

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

Если вызван System.gc( ), то финализация происходит для всех объектов. Это не было необходимо с предыдущей реализацией JDK, хотя документация заявляла обратное. Кроме того, вы увидите, что кажется, что нет каких-то различий, произошел ли вызов System.runFinalization( ).

Однако вы увидите, что если System.gc( ) вызывается после того, как все объекты будут созданы и работа с ними будет завершена, то будут вызваны все методы финализации. Если вы не вызываете System.gc( ), то только некоторые из объектов будут финализированы. В Java 1.1 метод System.runFinalizersOnExit( ) был введен, чтобы являться причиной, заставляющей запускать всех методов финализации при выходе из программы, но при этом в дизайне появлялось много ошибок, поэтому метод устарел и был заменен. Это дает представление о том, какие искания предпринимали разработчики Java в попытках решить проблемы сбора мусора и финализации. Мы можем только надеяться, что эти вещи достаточно хорошо разработаны в Java 2.

Предыдущая программа показывает, что обещание, что все финализации всегда выполняются, справедлива только, если вы явно навязываете, чтобы это происходило. Если вы не вызываете System.gc( ), на выходе вы получите примерно следующее:

Created 47
Beginning to finalize after 3486 Chairs have been created
Finalizing Chair #47, Setting flag to stop Chair creation
After all Chairs have been created:
total created = 3881, total finalized = 2684
bye!

Таким образом, не все финализации вызываются до того, как программа завершится. Если вызван System.gc( ), это приведет к финализации и разрушению всех объектов, которые более не используются в этот момент.

Помните, что ни сборка мусора, ни финализация не гарантирована. Если Виртуальная Java Машина (JVM) не приближается к переполнению памяти, то она (что очень мудро) не тратит время на освобождение памяти с помощью сборки мусора.

Смертельное состояние

В общем случае вы не можете полагаться на вызов finalize( ), и вы должны создавать другую функцию “очистки” и явно вызывать ее. Это означает, что finalize( ) полезен только для задач очистки памяти, которые большинство программистов чаще всего не используют. Однако есть очень интересное использование finalize( ), при котором не предполагается, что метод вызывается каждый раз. Это проверка состояния смерти [29] объекта.

В том месте, где вы более не интересуетесь объектом — когда он готов к тому, чтобы быть очищенным — такой объект должен быть в том состоянии, когда его память может быть безопасно освобождена. Например, если объект представляет собой открытый файл, то файл должен быть закрыт программистом прежде, чем объект подвергнется сборке мусора. Если любая часть объекта неправильно очищена, то вы получите ошибку в вашей программе, которую будет очень трудно обнаружить. Значение finalize( ) в том, что он может быть использован для определения такого состояния, даже если он еще не был вызван. Если срабатывает одна из финализаций, выявляя ошибку, то вы обнаруживаете проблему, о которой вы всегда можете позаботиться.

Вот простой пример, который вы можете использовать:

//: c04:DeathCondition.java
// Использование finalize() для обнаружения объекта,
// который не был правильно очищен.

class Book {
  boolean checkedOut = false;
  Book(boolean checkOut) { 
    checkedOut = checkOut; 
  }
  void checkIn() {
    checkedOut = false;
  }
  public void finalize() {
    if(checkedOut)
      System.out.println("Error: checked out");
  }
}

public class DeathCondition {
  public static void main(String[] args) {
    Book novel = new Book(true);
    // Правильная очистка:
    novel.checkIn();
    // Бросаем ссылку, забываем очистить:
    new Book(true);
    // Форсируем сбор мусора и финализацию:
    System.gc();
  }
} ///:~

Состояние смерти состоит в том, что предполагается, что все объекты Book проверяются перед сборкой мусора, но в main( ) программист не выполняет ни один из объектов. Без finalize( ) с проверкой состояния смерти было бы трудно обнаружить ошибку.

Обратите внимание, что System.gc( ) используется для форсирования финализации (и должно выполнятся во время разработке программы для ускорения отладки). Но даже если это не так, то есть высокая вероятность того, что блуждающий Book будет обязательно обнаружен при повторных запусках программы (имеется в виду, что программа занимает достаточно места, что является причиной запуска сборщика мусора).

Как работает сборщик мусора

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

Например, вы можете думать о куче C++, как о загоне, в котором каждый объект содержится на своем участке площади. Это реальное положение позже было отброшено, и должно использоваться вновь. В некоторых JVM куча Java слегка отличается; она немного похожа на ленту конвейера, которая перемещается вперед всякий раз, когда вы резервируете новый объект. Это означает, что выделение хранилища для нового объекта происходит удивительно быстро. “Указатель кучи” просто перемещается вперед на не тронутую территорию, так что этот процесс по эффективности приближается к операциям со стеком в C++ (конечно в этом есть небольшой дополнительный расход на двойную бухгалтерию, но это ничто по сравнению с поиском хранилища).

Теперь вы можете заметить, что куча, фактически, не является лентой конвейера, и если вы трактуете ее таким образом, в конечном счете станете разбивать память на страницы (что советуется для увеличения производительности). Хитрость в том, что когда сборщик мусора выступает на сцену, и пока он собирает мусор, он компонует все объекты в куче так, что вы получаете реальное перемещение “указателя кучи” ближе к началу ленты конвейера, что предотвращает переполнение страницы. Сборщик мусора заново упорядочивает вещи и делает возможным использование модели высокоскоростной, постоянно свободной кучи для выделения хранилища.

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

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

В описанном здесь подходе JVM использует адаптивную схему сбора мусора, а то, что она делает с живыми объектами - это обнаруживает зависимости от вариантов использования. Один из этих вариантов - это остановись-и-копируй. Это означает, что — по некоторым причинам, которые вы увидите — программа сначала останавливается (эта схема не относится к фоновым). Затем, каждый найденный живой объект копируется из одной кучи в другую. То что осталось, собирается. Кроме того, когда объекты копируются в новую кучу, они упаковываются один к одному, таким образом, упаковывается новая куча (что позволяет быстро распутать новое хранилище до конца, как описано выше).

Конечно, когда объект перемещается из одного места в другое, все ссылки, которые указывают на этот объект, должны быть изменены. Ссылки, которые привели из кучи или статического хранилища к этому объекту также могут изменится, но здесь могут быть и другие ссылки, указывающие на этот объект, которые будут обнаружены позднее, во время обхода. Они будут фиксироваться при обнаружении (вы можете представить себе таблицу, которая ставит в соответствие старые адреса и новые).

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

Вторая проблема в копировании. Как только ваша программа стабилизируется, она будет генерировать мало мусора, или не генерировать его вообще. Несмотря на это копирующий сборщик будет копировать всю память из одного места в другое, что не экономично. Чтобы предотвратить это, некоторые JVM определяют, что не было сгенерировано нового мусора, и переключаются на другую схему (это “адаптивная” часть). Эта другая схема называется пометка и уборка. Эта схема была реализована в ранних версиях JVM от Sun и использовалась все время. Для общего использования, пометка и уборка достаточно медленна, но когда вы знаете, что генерируете мало мусора, или не генерируете его вообще, она быстра.

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

“Остановка-и-копирование” обращаются к идее того, что этот тип сборки мусора не выполняется в фоновом режиме; вместо этого программа останавливается, пока работает СМ. В литературе от Sun вы найдете много ссылок на сборку мусора, как на низкоприоритетный фоновый процесс, но это означает, что СМ не реализует этот способ, по крайней мере, в ранних версиях Sun JVM. Вместо этого сборщик мусора Sun запускается, когда памяти становится мало. Кроме того, пометка-и-уборка требует, чтобы программа остановилась.

Как упоминалось ранее, в описанной здесь JVM память резервируется большими блоками. Если вы резервируете большой объект, он получает свой собственный блок. Строгие правила остановки-и-копирования требуют копирования каждого живого объекта из исходной кучи в новую до того, как вы сможете освободить старую, что занимает много памяти. С блоками, СМ может обычно использовать мертвые блоки для копирования объектов, когда они будут собраны. Каждый блок имеет счет генерации, для слежения, жив ли он. В обычном случае создаются только блоки, так как СМ производит компактное расположение; все другие блоки получают увеличение своего счета генерации, указывающее, что на них есть ссылка откуда-либо. Это обработка обычного случая большинства временных объектов с коротким временем жизни. Периодически производится полная уборка — большие объекты все еще не копируются (просто получают увеличения счета генерации), а блоки, содержащие маленькие объекты, копируются и уплотняются. JVM следит за эффективностью СМ, и если она становится расточительной относительно времени, поскольку все объекты являются долгожителями, то она переключается на пометку-и-уборку. Аналогично, JVM следит, насколько успешна пометка-и-уборка, и, если куча становится слишком фрагментирована, вновь переключается на остановку-и-копирование. Это то, что составляет “адаптивную” часть, так что в завершении можно сказать труднопроизносимую фразу: “адаптивность генерирует остановку-и-копирование с пометкой-и-уборкой”.

Есть несколько дополнительный возможностей ускорения в JVM. Особенно важно включение операции загрузчика и Just-In-Time (JIT) компилятора. Когда класс должен быть загружен (обычно перед тем, как вы хотите создать объект этого класса), происходит поиск .class файла и байт-код для класса переносится в память. В этом месте один из подходов - упростит JIT весь код, но здесь есть два недостатка: это займет немного больше времени, что отразится на продолжительности работы программы, увеличив ее, и это увеличит размер выполнения (байт-код значительно компактнее, чем расширенный JIT-код), а это приведет к разбиению на страницы, которые значительно замедлят программу. Альтернативой является ленивое вычисление, что означает, что код не является JIT-компилированные, если это ненужно. Таким образом, код, который никогда не выполняется, никогда не будет компилироваться JIT.

Инициализация членов

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

  void f() {
    int i;
    i++;
  }

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

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

//: c04:InitialValues.java
// Показываются значения инициализации по умолчанию.

class Measurement {
  boolean t;
  char c;
  byte b;
  short s;
  int i;
  long l;
  float f;
  double d;
  void print() {
    System.out.println(
      "Data type      Initial value\n" +
      "boolean        " + t + "\n" +
      "char           [" + c + "] "+ (int)c +"\n"+
      "byte           " + b + "\n" +
      "short          " + s + "\n" +
      "int            " + i + "\n" +
      "long           " + l + "\n" +
      "float          " + f + "\n" +
      "double         " + d);
  }
}

public class InitialValues {
  public static void main(String[] args) {
    Measurement d = new Measurement();
    d.print();
    /* В этом случае вы также можете сказать:
    new Measurement().print();
    */
  }
} ///:~

Вот что программа печатает на выходе:

Data type      Initial value
boolean        false
char           [ ] 0
byte           0
short          0
int            0
long           0
float          0.0
double         0.0

Значение char - это ноль, который печатается как пробел.

Позже вы увидите, что когда вы определяете ссылку на объект внутри класса и инициализируете ее новым объектом, эта ссылка получает специальное значение null (которое является ключевым словом Java).

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

Указание инициализации

Что произойдет, если вы захотите присвоить переменной начальное значение? Один прямой способ сделать это - это просто присвоить значение в точке определения переменной в классе. (Обратите внимание, что вы не можете сделать это в C++, хотя C++ всегда пробует все новое.) Вот определение полей в классе Measurement, который изменен для обеспечения начальных значений:

class Measurement {
  boolean b = true;
  char c = 'x';
  byte B = 47;
  short s = 0xff;
  int i = 999;
  long l = 1;
  float f = 3.14f;
  double d = 3.14159;
  //. . .

Вы также можете инициализировать не примитивные объекты таким же способом. Если Depth - это класс, вы можете вставить переменную и инициализировать ее следующим образом:

class Measurement {
  Depth o = new Depth();
  boolean b = true;
  // . . .

Если вы не передадите o начальное значение и, тем не менее, попробуете использовать ее, вы получите ошибку времени выполнения, называемую исключением (это описано в Главе 10).

Вы даже можете вызвать метод для обеспечения начального значения:

class CInit {
  int i = f();
  //...
}

Конечно, этот метод может иметь аргументы, но эти аргументы не могут быть другими членами класса, которые еще не инициализированы. Таким образом, вы можете сделать так:

class CInit {
  int i = f();
  int j = g(i);
  //...
}

Но вы не можете сделать этого:

class CInit {
  int j = g(i);
  int i = f();
  //...
}

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

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

Инициализация в конструкторе

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

class Counter {
  int i;
  Counter() { i = 7; }
  // . . .

то i сначала будет инициализирована 0, а затем 7. Это верно для всех примитивных типов и для ссылок на объекты, включая те, которые имеют явную инициализацию в точке определения. По этой причине компилятор не пробует ограничить вас в инициализации элементов в любом месте конструктора, или перед тем, как они будут использоваться — инициализация гарантирована [30].

Порядок инициализации

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

//: c04:OrderOfInitialization.java
// Демонстрация порядка инициализации.

// Когда конструктор вызывается для создания
// объекта Tag, вы увидите сообщение:
class Tag {
  Tag(int marker) {
    System.out.println("Tag(" + marker + ")");
  }
}

class Card {
  Tag t1 = new Tag(1); // Перед конструктором
  Card() {
    // Указывает, что мы в конструкторе:
    System.out.println("Card()");
    t3 = new Tag(33); // Повторная инициализация t3
  }
  Tag t2 = new Tag(2); // После конструктора
  void f() {
    System.out.println("f()");
  }
  Tag t3 = new Tag(3); // В конце
}

public class OrderOfInitialization {
  public static void main(String[] args) {
    Card t = new Card();
    t.f(); // Показывает завершение конструктора
  }
} ///:~

В Card объекты Tag определяются вперемешку для обеспечения, чтобы они все были инициализированы до входа в конструктор и до того, как что-то еще случится. Кроме того, t3 повторно инициализируется внутри конструктора. На выходе получим:

Tag(1)
Tag(2)
Tag(3)
Card()
Tag(33)
f()

Таким образом, ссылка t3 инициализируется дважды, один раз до входа в конструктор, а второй при вызове конструктора. (Первый объект выбрасывается, так что он может быть позже обработан сборщиком мусора.) Сначала это может показаться не эффективно, но это гарантирует правильную инициализацию — что могло бы произойти, если бы был определен перегруженный конструктор, который бы не инициализировал t3, и не было бы инициализации “по умолчанию” для t3 в точке определения?

Инициализация статических данных

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

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

//: c04:StaticInitialization.java
// Указание начальных значений в
// определении класса.

class Bowl {
  Bowl(int marker) {
    System.out.println("Bowl(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

class Table {
  static Bowl b1 = new Bowl(1);
  Table() {
    System.out.println("Table()");
    b2.f(1);
  }
  void f2(int marker) {
    System.out.println("f2(" + marker + ")");
  }
  static Bowl b2 = new Bowl(2);
}

class Cupboard {
  Bowl b3 = new Bowl(3);
  static Bowl b4 = new Bowl(4);
  Cupboard() {
    System.out.println("Cupboard()");
    b4.f(2);
  }
  void f3(int marker) {
    System.out.println("f3(" + marker + ")");
  }
  static Bowl b5 = new Bowl(5);
}

public class StaticInitialization {
  public static void main(String[] args) {
    System.out.println(
      "Creating new Cupboard() in main");
    new Cupboard();
    System.out.println(
      "Creating new Cupboard() in main");
    new Cupboard();
    t2.f2(1);
    t3.f3(1);
  }
  static Table t2 = new Table();
  static Cupboard t3 = new Cupboard();
} ///:~

Bowl позволяет вам наблюдать за созданием класса, а Table и Cupboard создают static-члены Bowl вперемешку в определении класса. Обратите внимание, что Cupboard создает не-static Bowl b3 перед static определением. На выводе вы видите, что произошло:

Bowl(1)
Bowl(2)
Table()
f(1)
Bowl(4)
Bowl(5)
Bowl(3)
Cupboard()
f(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f(2)
f2(1)
f3(1)

Static инициализация происходит только при необходимости. Если вы не создаете объект Table и никогда не обращаетесь к Table.b1 или Table.b2, static Bowl b1 и b2 никогда не будут созданы. Однако они инициализируются, только когда создается первый объект Table (или при возникновении первого static доступа static). После этого static объекты не инициализируются повторно.

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

Полезно просуммировать информацию о процессе создания объекта. Рассмотрим класс с названием Dog:

  1. В начале создания объекта типа Dog, или при первом обращении к static методу или static полу класса Dog, интерпретатор Java должен найти Dog.class, что он выполняет, производя поиск по classpath.
  2. После загрузки Dog.class (создания объекта Class, о котором вы узнаете позже), выполняются все static инициализации. Таким образом, static инициализации выполняются только однажды, когда объект Class загружается в первое время.
  3. Когда вы создаете new Dog( ), в процессе создания объекта Dog сначала резервируется хранилище для объекта Dog в куче.
  4. Это хранилище заполняется нулями, автоматически присваивая всем переменным примитивных типов этого объекта Dog их начальное значение (ноль для числовых и эквивалент для boolean и char), а все ссылки в null.
  5. Выполняются все инициализации, производящиеся в точке определения.
  6. Выполняется конструктор. Как вы увидите в Главе 6, это может стать источником повышенной активности, особенно когда привлекается наследование.

Явная инициализация static

Java позволяет вам сгруппировать другие static инициализации внутри специального “static предложения конструирования” (иногда называемому статическим блоком) в классе. Это выглядит так:

class Spoon {
  static int i;
  static {
    i = 47;
  }
  // . . .

Он выглядит как метод, но это просто ключевое слово static, за которым следует тело метода. Этот код, как и другие static инициализации, выполняется только однажды, в первый раз, когда вы создаете объект этого класса или при первом обращении к static члену класса (даже если вы никогда не создадите объект этого класса). Например:

//: c04:ExplicitStatic.java
// Явная static инициализация
// с предложением "static".

class Cup {
  Cup(int marker) {
    System.out.println("Cup(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

class Cups {
  static Cup c1;
  static Cup c2;
  static {
    c1 = new Cup(1);
    c2 = new Cup(2);
  }
  Cups() {
    System.out.println("Cups()");
  }
}

public class ExplicitStatic {
  public static void main(String[] args) {
    System.out.println("Inside main()");
    Cups.c1.f(99);  // (1)
  }
  // static Cups x = new Cups();  // (2)
  // static Cups y = new Cups();  // (2) 
} ///:~

Static инициализаторы для Cups запускаются, либо когда происходит обращение к static объекту c1 в строке, помеченной (1), а если строка (1) закомментирована, то в строке, помеченной (2), если ее раскомментировать. Если и строка (1), и (2) закомментированы, static инициализация для Cups никогда не происходит. Также не имеет значения, если одна из двух строк, помеченных (2) раскомментированы; статическая инициализация происходит только один раз.

Не статическая инициализация экземпляра

Java обеспечивает аналогичный синтаксис для не static переменных для каждого объекта. Вот пример:

//: c04:Mugs.java
// Java "Инициализация экземпляра".

class Mug {
  Mug(int marker) {
    System.out.println("Mug(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

public class Mugs {
  Mug c1;
  Mug c2;
  {
    c1 = new Mug(1);
    c2 = new Mug(2);
    System.out.println("c1 & c2 initialized");
  }
  Mugs() {
    System.out.println("Mugs()");
  }
  public static void main(String[] args) {
    System.out.println("Inside main()");
    Mugs x = new Mugs();
  }
} ///:~

Вы можете видеть, что предложение инициализации экземпляра:

  {
    c1 = new Mug(1);
    c2 = new Mug(2);
    System.out.println("c1 & c2 initialized");
  }

выглядит точно так же, как и предложение статической инициализации, за исключением отсутствия ключевого слова static. Этот синтаксис необходим для поддержки инициализации анонимного внутреннего класса (смотрите Главу 8).

Инициализация массива

Инициализация массивов в C++ подвержена ошибкам и утомительна. C++ используют агрегатную инициализацию, чтобы сделать ее более безопасной [31]. Java не имеет “агрегатности”, как С++, так как все, что есть в Java - это объекты. Он имеет массивы, которые поддерживают инициализацию массивов.

Массив - это просто последовательность либо объектов, либо примитивных типов, которые все имеют один тип и упакованы вместе под одним идентификатором. Массивы определяются и используются с квадратными скобками оператора индексирования [ ]. Для определения массива вы просто указываете имя типа, за которым следуют пустые квадратные скобки:

int[] a1;

Вы также можете поместить квадратные скобки после идентификатора, что имеет то же самое значение:

int a1[];

Это подтверждает ожидания программистов C и C++. Однако, форма, вероятно, имеет более гибкий синтаксис, так как она объявляет тип “массив int”. Этот стиль будет использоваться в этой книге.

Компилятор не позволяет вам объявить величину массива. Это происходит из-за свойств “ссылок”. Все, что вы имеете в этой точке - это ссылка на массив, и здесь не резервируется место для массива. Для создания хранилища для массива вы должны написать выражение инициализации. Для массивов, инициализация может быть выполнена в любом месте вашего кода, но вы также можете использовать особый вид выражения инициализации, которая должна происходить в точке создания. Эта особая инициализация обеспечивает набор значений, заключенных в фигурные скобки. О резервировании хранилища (эквивалентно использованию new) в этом случае заботится компилятор. Например:

int[] a1 = { 1, 2, 3, 4, 5 };

Почему вы иногда определяете ссылку на массив без массива?

int[] a2;

Потому что возможно присвоить один массив в Java другому, так что вы можете сказать:

a2 = a1;

На самом деле вы выполняете копирование ссылок, как продемонстрировано тут:

//: c04:Arrays.java
// Массив примитивных типов.

public class Arrays {
  public static void main(String[] args) {
    int[] a1 = { 1, 2, 3, 4, 5 };
    int[] a2;
    a2 = a1;
    for(int i = 0; i < a2.length; i++)
      a2[i]++;
    for(int i = 0; i < a1.length; i++)
      System.out.println(
        "a1[" + i + "] = " + a1[i]);
  }
} ///:~

Вы можете видеть, что a1 получает значения инициализации, в то время как a2 не имеет его; a2 присваивается позже — в этом случае, с помощью другого массива.

Здесь есть кое-что новое: все массивы имеют внутренний член (не зависимо от того, есть ли массив объектов, или массив примитивных типов), который вы можете опросить — но не изменить — и он скажет вам, сколько элементов есть в массиве. Этот член - length. Так как массивы в Java, как и в C и C++, начинают счет элементов с нуля, старший элемент имеет индекс length - 1. Если вы выйдете за пределы, C и C++ примут это и позволят вам пройтись по вашей памяти, что будет являться источником многих ошибок, трудных в обнаружении. Однако Java защищает вас от этой проблемы, выдавая ошибку времени выполнения (исключение, описанное в Главе 10), если вы выйдете за пределы. Конечно, проверка каждого обращения к массиву влияет на время и код, и нет способа отключить ее, в результате чего доступ к массиву может стать источником неэффективности в вашей программе, если этот доступ происходит в критичном участке. Для безопасности Internet и продуктивности программистов, разработчики Java подумали, что это будет достаточно удобно.

Что, если вы не знаете, сколько элементов вам потребуется в вашем массиве, когда вы пишите программу? Вы просто используете new для создания элементов массива. Здесь new работает даже для создания массива примитивных типов (new не может создавать не массив примитивов):

//: c04:ArrayNew.java
// Создание массивов с помощью.
import java.util.*;

public class ArrayNew {
  static Random rand = new Random();
  static int pRand(int mod) {
    return Math.abs(rand.nextInt()) % mod + 1;
  }
  public static void main(String[] args) {
    int[] a;
    a = new int[pRand(20)];
    System.out.println(
      "length of a = " + a.length);
    for(int i = 0; i < a.length; i++)
      System.out.println(
        "a[" + i + "] = " + a[i]);
  }
} ///:~

Так как размер массива выбирается случайно (используя метод pRand( )), ясно, что создание массива происходит во время выполнения. Кроме того, вы видите на выходе программы, что массив элементов примитивных типов автоматически инициализируется “пустыми” значениями. (Для чисел и char - это ноль, а для boolean - это false).

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

int[] a = new int[pRand(20)];

Если вы имеете дело с массивом не примитивных объектов, вы должны всегда использовать new. Это происходит из-за использования ссылок, так как вы создаете массив ссылок. Относительно типа-оболочки Integer, который является классом, а не примитивным типом:

//: c04:ArrayClassObj.java
// Создание массива не примитивных объектов.
import java.util.*;

public class ArrayClassObj {
  static Random rand = new Random();
  static int pRand(int mod) {
    return Math.abs(rand.nextInt()) % mod + 1;
  }
  public static void main(String[] args) {
    Integer[] a = new Integer[pRand(20)];
    System.out.println(
      "length of a = " + a.length);
    for(int i = 0; i < a.length; i++) {
      a[i] = new Integer(pRand(500));
      System.out.println(
        "a[" + i + "] = " + a[i]);
    }
  }
} ///:~

Здесь, даже после вызова new для создания массива:

Integer[] a = new Integer[pRand(20)];

есть только массив ссылок, и пока ссылки не будут инициализированы путем создания новых объектов Integer, инициализация будет не закончена:

a[i] = new Integer(pRand(500));

Однако если вы забудете создать объект, вы получите исключение времени выполнения, когда попробуете прочесть пустую ячейку массива.

Взгляните на формирование объекта String внутри инструкции печати. Вы увидите, что ссылка на объект Integer автоматически конвертируется, для производства String, представляющую значение внутри объекта.

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

//: c04:ArrayInit.java
// Инициализация массива.

public class ArrayInit {
  public static void main(String[] args) {
    Integer[] a = {
      new Integer(1),
      new Integer(2),
      new Integer(3),
    };

    Integer[] b = new Integer[] {
      new Integer(1),
      new Integer(2),
      new Integer(3),
    };
  }
} ///:~

Иногда это полезно, но это более ограниченный способ, так как размер массива определяется во время компиляции. Последняя запятая в списке инициализации не обязательна. (Эта особенность облегчает обслуживание длинных списков.)

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

//: c04:VarArgs.java
// Использование синтаксиса массива для
// списка переменной длины.

class A { int i; }

public class VarArgs {
  static void f(Object[] x) {
    for(int i = 0; i < x.length; i++)
      System.out.println(x[i]);
  }
  public static void main(String[] args) {
    f(new Object[] { 
        new Integer(47), new VarArgs(), 
        new Float(3.14), new Double(11.11) });
    f(new Object[] {"one", "two", "three" });
    f(new Object[] {new A(), new A(), new A()});
  }
} ///:~

В этом месте есть не много вещей, которые вы можете сделать с этими неизвестными объектами, и эта программа использует автоматическое преобразование в String для получения некоторой пользы от каждого объекта Object. В Главе 12, которая описывает идентификацию типа времени выполнения (RTTI), вы выучите о том, как определять точный тип каждого объекта, так чтобы вы смогли делать более интересные вещи.

Многомерные массивы

Java позволяет вам легко создавать многомерные массивы:

//: c04:MultiDimArray.java
// Создание многомерных массивов.
import java.util.*;

public class MultiDimArray {
  static Random rand = new Random();
  static int pRand(int mod) {
    return Math.abs(rand.nextInt()) % mod + 1;
  }
  static void prt(String s) {
    System.out.println(s);
  }
  public static void main(String[] args) {
    int[][] a1 = {
      { 1, 2, 3, },
      { 4, 5, 6, },
    };
    for(int i = 0; i < a1.length; i++)
      for(int j = 0; j < a1[i].length; j++)
        prt("a1[" + i + "][" + j +
            "] = " + a1[i][j]);
    // 3-х мерный массив фиксированной длины:
    int[][][] a2 = new int[2][2][4];
    for(int i = 0; i < a2.length; i++)
      for(int j = 0; j < a2[i].length; j++)
        for(int k = 0; k < a2[i][j].length;
            k++)
          prt("a2[" + i + "][" +
              j + "][" + k +
              "] = " + a2[i][j][k]);
    // 3-х мерный массив с векторами переменной длины:
    int[][][] a3 = new int[pRand(7)][][];
    for(int i = 0; i < a3.length; i++) {
      a3[i] = new int[pRand(5)][];
      for(int j = 0; j < a3[i].length; j++)
        a3[i][j] = new int[pRand(5)];
    }
    for(int i = 0; i < a3.length; i++)
      for(int j = 0; j < a3[i].length; j++)
        for(int k = 0; k < a3[i][j].length;
            k++)
          prt("a3[" + i + "][" +
              j + "][" + k +
              "] = " + a3[i][j][k]);
    // Массив не примитивных объектов:
    Integer[][] a4 = {
      { new Integer(1), new Integer(2)},
      { new Integer(3), new Integer(4)},
      { new Integer(5), new Integer(6)},
    };
    for(int i = 0; i < a4.length; i++)
      for(int j = 0; j < a4[i].length; j++)
        prt("a4[" + i + "][" + j +
            "] = " + a4[i][j]);
    Integer[][] a5;
    a5 = new Integer[3][];
    for(int i = 0; i < a5.length; i++) {
      a5[i] = new Integer[3];
      for(int j = 0; j < a5[i].length; j++)
        a5[i][j] = new Integer(i*j);
    }
    for(int i = 0; i < a5.length; i++)
      for(int j = 0; j < a5[i].length; j++)
        prt("a5[" + i + "][" + j +
            "] = " + a5[i][j]);
  }
} ///:~

Код, использующийся для печати, использует length, так что он не зависит от того, имеет ли массив фиксированную длину.

Первый пример показывает многомерный массив примитивных типов. Вы ограничиваете каждый вектор в массиве с помощью фигурных скобок:

    int[][] a1 = {
      { 1, 2, 3, },
      { 4, 5, 6, },
    };

Каждая пара квадратных скобок переносит нас на новый уровень в массиве.

Второй пример показывает трехмерный массив, резервируемый с помощью new. Здесь весь массив резервируется сразу:

int[][][] a2 = new int[2][2][4];

А в третьем примере показано, что каждый вектор массива, создающий матрицу, может быть произвольной длины:

    int[][][] a3 = new int[pRand(7)][][];
    for(int i = 0; i < a3.length; i++) {
      a3[i] = new int[pRand(5)][];
      for(int j = 0; j < a3[i].length; j++)
        a3[i][j] = new int[pRand(5)];
    }

Первый new создает массив произвольной длиной первого элемента, а остальные элементы не определены. Второй new, внутри цикла for, заполняет элементы, но оставляет третий индекс неопределенным, пока вы не введете третий new.

Вы увидите на выводе, что значения массива автоматически инициализируются нулями, если вы не передадите им явно начальные значения.

Вы можете работать с массивами не примитивных объектов точно таким же образом, что показано в четвертом примере, демонстрирующем возможность помещения множества выражений new в фигурных скобках:

    Integer[][] a4 = {
      { new Integer(1), new Integer(2)},
      { new Integer(3), new Integer(4)},
      { new Integer(5), new Integer(6)},
    };

Пятый пример показывает, как можно построить массив не примитивных объектов по частям:

    Integer[][] a5;
    a5 = new Integer[3][];
    for(int i = 0; i < a5.length; i++) {
      a5[i] = new Integer[3];
      for(int j = 0; j < a5[i].length; j++)
        a5[i][j] = new Integer(i*j);
    }

i*j - это просто помещает отличное от нуля значение в Integer.

Резюме

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

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

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

Упражнения

Решения для выбранных упражнений могут быть найдены в электронной документации The Thinking in Java Annotated Solution Guide, доступной за малую плату на www.BruceEckel.com.
  1. Создайте класс с конструктором по умолчанию (который не принимает аргументов), печатающий сообщение. Создайте объект этого класса.
  2. Добавьте перегруженный конструктор к Упражнению 1, который принимает аргумент типа String и печатает его наряду с вашим сообщением.
  3. Создайте массив ссылок на объекты вашего класса из Упражнения 2, но не создавайте объекты для помещения их ссылок в массив. Когда вы запустите программу, обратите внимание, есть ли сообщения об инициализации, которые печатаются при вызове конструктора.
  4. Завершите Упражнение 3, создав объекты, и присоедините их к ссылкам в массиве.
  5. Создайте массив из объектов String и присоедините строку к каждому элементу. Распечатайте массив, используя цикл for.
  6. Создайте класс с названием Dog с перегруженным методом bark( ). Этот метод должен перегружаться, основываясь на различных примитивных типах данных, и печатать различные типы лая, завывания и т.п., в зависимости от того, какая перегруженная версия вызвана. Напишите main( ), который вызывает различные версии.
  7. Измените Упражнение 6 так, чтобы два разных перегруженных метода имели два аргумента (двух различных типов), но в разном порядке. Проверьте как это работает.
  8. Создайте класс без конструктора, а затем создайте объект этого класса в main( ) для проверки того, что конструктор по умолчанию синтезируется автоматически.
  9. Создайте класс с двумя методами. В первом методе вызовите второй дважды: первый раз без использования this, а второй раз, используя this.
  10. Создайте класс с двумя (перегруженными) конструкторами. Используя this, вызовите второй конструктор внутри первого.
  11. Создайте класс с методом finalize( ), который печатает сообщение. В main( ) создайте объект вашего класса. Объясните поведение вашей программы.
  12. Измените Упражнение 11 так, чтобы ваш finalize( ) вызывался всегда.
  13. Создайте класс, называемый Tank, который может быть заполнен и опустошен, и имеет смертельное состояние, при котором он должен быть опустошен во время очистки объекта. Напишите finalize( ), который проверяет смертельное состояние. В main( ) проверьте возможные сценарии, которые возникают при использовании вашего Tank.
  14. Создайте класс, содержащий int и char, которые не инициализируются, и распечатайте их значения, чтобы проверить, что Java выполнил инициализацию по умолчанию.
  15. Создайте класс, содержащий не инициализированную ссылку на String. Продемонстрируйте, что эта ссылка инициализируется Java значением null.
  16. Создайте класс с полем String, которое инициализируется в точке определения, и другое поле, которое инициализируется конструктором. Какие отличия есть в этих двух подходах?
  17. Создайте класс с полем static String, которое инициализируется в точке определения, и другое, которое инициализируется в блоке static. Добавьте static метод, который печатает оба поля и демонстрирует, что оба они инициализируются до использования.
  18. Создайте класс с String, который инициализируется, используя “инициализацию экземпляра”. Опишите использование этой особенности (отличной от тех, которые описаны в этой книге).
  19. Напишите метод, который создает и инициализирует двумерный массив типа double. Размер массива определяется аргументами метода, а диапазон начальных значений определяется начальным и конечным значением, которые так же передаются, как аргументы метода. Создайте второй метод, который будет печатать массив, сгенерированный первым методом. В main( ) проверьте методы, создав и распечатав несколько массивов с различным размером.
  20. Повторите Упражнение 19 для трехмерного массива.
  21. Закомментируйте строку, помеченную (1) в ExplicitStatic.java и проверьте, что предложение статической инициализации не вызывается. Теперь раскомментируйте одну из строк, помеченных (2) и проверьте, что предложение статической инициализации вызвано. Теперь раскомментируйте вторую строку, помеченную (2) и проверьте, что статическая инициализация происходит лишь однажды.
  22. Поэкспериментируйте с Garbage.java, запуская программу, используя такие аргументы, как “gc”, “finalize” или “all”. Повторите процесс и посмотрите, обнаружите ли вы какие-нибудь шаблоны на выходе. Измените код так, чтобы System.runFinalization( ) вызывался перед System.gc( ) и посмотрите результат.

[27] В части литературы по Java от Sun они говорят об этом неуклюжим, но описательным именем “конструктор без аргументов”. Термин “конструктор по умолчанию” был в использовании уже много лет, поэтому он используется и сейчас.

[28] Одна из причин, по которой это возможно, если вы передаете ссылку на объект в static метод. Поэтому, через ссылку (которая теперь аналогична this), вы можете вызвать не static методы и получить доступ к не static полям. Но обычно, если вы хотите сделать что-то подобное, вы сделаете обычный не static метод.

[29] Термин, который был введен Bill Venners (www.artima.com) во время семинара, который мы с ним проводили вместе.

[30] В отличие от этого, C++ имеет список инициализирующих конструкторов, который является причиной инициализации до попадания в тело конструктора и является обязательным для объектов. Смотрите Thinking in C++, 2nd edition (доступную на CD ROM, поставляемый с книгой и на www.BruceEckel.com).

[31] Смотрите Thinking in C++, 2nd edition о более полном описании агрегатной инициализации в C++.

[ Предыдущая глава ] [ Оглавление ] [ Содержание ] [ Индекс ] [ Следующая глава ]