Одной из наиболее притягательных возможностей языка Java является
возможность повторного использования кода. Но что действительно "революционно",
так это наличие возможности выполнять не только простое копирование и изменение
этого кода.
Такой подход использован в процедурных языках программирования, наподобие C, но он работает не очень хорошо. Как и все в Java, решение с повторным использованием кода вертится вокруг классов. Вы повторно используете код, создавая новый класс, но вместо того, что бы создавать его с нуля Вы используете уже существующие классы, которые кто-то уже создал и отладил.
Уловка в том, что бы использовать классы без копания в их исходном коде. В этой главе вы увидите два способа достижения этого. Первый - почти прямой: Вы просто создаете объекты ваших уже существующих классов внутри нового класса. Это называется "композиция" , потому, что новый класс создается из объектов уже существующих классов. Вы просто повторно используете функциональность кода, но не его самого.
Второй подход более искусный. Суть его в том, что создается новый класс с типом существующего класса. Вы буквально берете оболочку (интерфейс) существующего класса и добавляете свой код к нему без модификации существующего класса. Этот магический акт называется "наследование", и компилятор языка при этом выполняет большую часть работы. Наследование является одним из краеугольных камней объектно-ориентированного программирования и имеет более широкий смысл, который будет раскрыт в главе 7.
Это исключительно, но синтаксис и поведение идентичны для обоих способов, для композиции и наследования (обусловлено тем, что оба пути создают новые типы из существующих типов). В этой главе Вы узнаете об обоих этих механизмах повторного использования.
До сих пор, композиция достаточно часто использовалась, Вы просто помещали ссылку на объект внутрь нового класса. Для примера, представьте себе, что Вы хотите получить объект, который хранит различные объекты типа String, пару примитивных типов и объект другого класса. Для не примитивных объектов Вы помещаете ссылки внутри вашего класса, но примитивные типы Вы определяете напрямую:
//: c06:SprinklerSystem.java // Композиция для повторного использования кода. class WaterSource { private String s; WaterSource() { System.out.println("WaterSource()"); s = new String("Constructed"); } public String toString() { return s; } } public class SprinklerSystem { private String valve1, valve2, valve3, valve4; WaterSource source; int i; float f; void print() { System.out.println("valve1 = " + valve1); System.out.println("valve2 = " + valve2); System.out.println("valve3 = " + valve3); System.out.println("valve4 = " + valve4); System.out.println("i = " + i); System.out.println("f = " + f); System.out.println("source = " + source); } public static void main(String[] args) { SprinklerSystem x = new SprinklerSystem(); x.print(); } } ///:~
Один из методов определенных в WaterSource особенный - toString( ). Вы узнаете позже, что все не примитивные объекты имеют метод toString( ) и он вызывается в особых ситуациях, когда компилятор хочет получить String, но эти объекты не являются таковыми. Так в выражении:
System.out.println("source = " + source);
компилятор видит Вашу попытку добавить объект String ("source = ") к WaterSource. И при этом для компилятора нет никакой разницы, поскольку Вы можете только добавить строку (String) к другой строке (String), при этом он "скажет": "Я преобразую source в String вызвав метод toString( )!" После выполнения этой операции компилятор объединит эти две строки и передаст результат в виде опять же строки в System.out.println( ). В любое время, когда вы захотите получить доступ к такой линии поведения с классом, Вам нужно только написать в нем метод toString( ) .
На первый взгляд, вы можете позволить Java принять на себя заботу об безопасности, потому, что компилятор автоматически создаст объекты для каждой ссылки, как в предыдущем коде. Например, вызов конструктора по умолчанию для WaterSource при инициализации source. Вывод печатаемых данных на самом же деле такой:
valve1 = null valve2 = null valve3 = null valve4 = null i = 0 f = 0.0 source = null
Примитивные типы-поля класса автоматически инициализируются в нулевое значение, как и было описано в главе 2. Но ссылки на объекты инициализируются в null и если Вы попытаетесь вызвать любой из этих методов, то Вы получите исключение. В действительности достаточно хорошо (и удобно) то, что Вы можете распечатать их без обработки исключения.
Этот пример дает понять, что компилятор только просто создает объект по умолчанию для каждой ссылки, потому, что в противном случае система может в отдельных случаях подвергнуться перегрузке. Если же Вы желаете инициализировать полностью эти ссылки, Вы можете сделать это такими способами:
При этом может быть уменьшена перегрузка системы в ситуациях, когда объектам нет необходимости быть созданным все время работы программы.
Все три подхода представлены ниже:
//: c06:Bath.java // Инициализация конструктора с композицией. class Soap { private String s; Soap() { System.out.println("Soap()"); s = new String("Constructed"); } public String toString() { return s; } } public class Bath { private String // Инициализация в точке определения: s1 = new String("Happy"), s2 = "Happy", s3, s4; Soap castille; int i; float toy; Bath() { System.out.println("Inside Bath()"); s3 = new String("Joy"); i = 47; toy = 3.14f; castille = new Soap(); } void print() { // Отложенная (ленивая) инициализация: if(s4 == null) s4 = new String("Joy"); System.out.println("s1 = " + s1); System.out.println("s2 = " + s2); System.out.println("s3 = " + s3); System.out.println("s4 = " + s4); System.out.println("i = " + i); System.out.println("toy = " + toy); System.out.println("castille = " + castille); } public static void main(String[] args) { Bath b = new Bath(); b.print(); } } ///:~
Заметьте, что в конструкторе Bath оператор выполняется до того, как произойдет инициализация. Если вы не проинициализируете объект в точке определения, то нет никакой гарантии, что Вы выполните инициализацию до того, как вы пошлете сообщение объекту и неизбежно получите исключение.
Ниже приведен вывод программы:
Inside Bath() Soap() s1 = Happy s2 = Happy s3 = Joy s4 = Joy i = 47 toy = 3.14 castille = Constructed
Когда вызывается print( ) он заполняется из s4 потому, что все поля были правильно инициализированы до того времени, когда они были использованы.
Наследование является неотъемлемой частью Java, впрочем, как и других ОО языков программирования. Это очевидно - Вы всегда осуществляете операцию наследования, когда создаете класс, даже если ваш класс не является наследником какого либо другого, потому, что Вы неявно наследуете стандартный корневой класс Java Object.
Синтаксис наследования похож на композицию, но процедура выполнения заметно отличается. Когда Вы наследуете, Вы "говорите": "Этот класс такой же, как тот старый класс!" Вы излагаете эту фразу в коде давая классу имя, как обычно, но до того, как начнете работать с телом класса, добавляете ключевое слово extends следующее до имени базового класса. Когда вы сделаете это, вы автоматически получите все поля данных и методы базового класса. Вот пример:
//: c06:Detergent.java // Свойства и синтаксис наследования. class Cleanser { private String s = new String("Cleanser"); public void append(String a) { s += a; } public void dilute() { append(" dilute()"); } public void apply() { append(" apply()"); } public void scrub() { append(" scrub()"); } public void print() { System.out.println(s); } public static void main(String[] args) { Cleanser x = new Cleanser(); x.dilute(); x.apply(); x.scrub(); x.print(); } } public class Detergent extends Cleanser { // Изменяем метод: public void scrub() { append(" Detergent.scrub()"); super.scrub(); // Вызываем метод базового класса } // Все методы наследования: public void foam() { append(" foam()"); } // Проверяем новый класс: public static void main(String[] args) { Detergent x = new Detergent(); x.dilute(); x.apply(); x.scrub(); x.foam(); x.print(); System.out.println("Testing base class:"); Cleanser.main(args); } } ///:~
Этот пример показывает несколько возможностей. Сперва в методе Cleanser append( ) , String-и конкатенируются с s при помощи оператора "+=", это один из операторов (с плюсом впереди), который перегружается Java для работы с типом String.
Во-вторых, оба Cleanser и Detergent содержат метод main( ). Вы можете создать main( ) для каждого из ваших классов и часто рекомендуется писать такой код для тестирования каждого из классов. Если же у Вас имеется множество классов в программе, то выполнится только метод main( ) того класса, который был вызван из командной стоки. Так что в этом случае, когда вы вызовите java Detergent, будет вызван метод Detergent.main( ) . Но так же вы можете вызвать java Cleanser для выполнения Cleanser.main( ), несмотря даже на то, что класс Cleanser не public . Эта техника помещения метода main( ) в каждый класс позволяет легко проверять каждый из классов программы по отдельности. И Вам нет необходимости удалять main( ) когда вы закончили проверки, Вы можете оставить его для будущих проверок.
Здесь Вы можете видеть, что Detergent.main( ) явно вызывает Cleanser.main( ) , передавая ему те же самые аргументы из командной строки(тем не менее, Вы могли были передать ему любой , массив элементов типа String).
Важно то, что все методы в Cleanser - public. Помните, если Вы оставите любой из спецификаторов доступа в состоянии по умолчанию, т.е. он будет friendly, то доступ к нему могут получить только члены этого же пакета. Поэтому в этом пакете все могут использовать эти методы, если у них нет спецификатора доступа. Detergent с эти проблем не имеет, к примеру. Но в любом случае, если класс из другого пакета попытается наследовать Cleanser он получит доступ только к членам со спецификатором public. Так что если Вы планируете использовать наследование, то в качестве главного правила делайте все поля private и все методы public. (protected так же могут получить доступ к наследуемым классам, но Вы узнаете об этом позже.) Естественно в частных случаях Вы должны делать поправки на эти самые частные случаи, но все равно это полезная линия поведения.
Замете, что Cleanser имеет набор методов из родительского интерфейса: append( ), dilute( ), apply( ), scrub( ), и print( ). Из-за того, что Detergent произошел от Ceanser (при помощи ключевого слова extends ) он автоматически получил все те методы, что есть в его интерфейсе, даже не смотря на то, что вы не видите их определенных в Detergent. Вы можете подумать о наследовании, а уже только затем о повторном использовании интерфейса.
Как видно в scrub( ) , возможно создать метод, который определяется в базовом классе, а затем уже его модифицировать. В таком случае, Вы можете захотеть вызвать метод внутри базового класса этот новый модифицированный метод. Но внутри scrub( ) вы не можете просто вызвать scrub( ), поскольку эта операция вызовет рекурсивный вызов, а это не то, что Вы хотите. Для разрешения этой проблемы в Java используется ключевое слово super , которое ссылается на superclass, который в свою очередь является классом, от которого произошел текущий класс. Поэтому выражение super.scrub( ) вызывает метод базового класса scrub( ).
При наследовании вы не ограничены в использовании методов базового класса. Вы можете так же добавлять новые методы в новый класс. Это сделать очень просто, нужно просто определить их. Метод foam( ) тому демонстрация.
В Detergent.main( ) вы можете увидеть, что у объекта Detergent Вы можете вызвать все методы, которые доступны в Cleanser так же, как и в Detergent (в том числе и foam( )).
До этого у нас было запутано два класса - базовый и произошедший от него вместо просто одного, это может привести в небольшое замешательство при попытке представить результирующий объект произведенный произошедшим (дочерним) классом. С наружи он выглядит, как новый класс имеющий тот же интерфейс, что и базовый класс и может иметь те же самые методы и поля. Но наследование не просто копирует интерфейс базового класса. Когда Вы создаете объект произошедшего (дочернего) класса он содержит еще и подобъект базового класса. Этот подобъект точно такой же, как если бы вы создали объект базового класса самостоятельно.
Естественно, что необходимо правильно и корректно проинициализировать этот подобъект и здесь есть только один гарантированный путь: осуществить инициализацию в конструкторе, путем вызова конструктора базового класса, который имеет все необходимые сведения и привилегии для осуществления инициализации самого базового класса. Java автоматически вставляет вызов базового класса в конструктор произошедшего (наследуемого) от этого класса. Следующий пример демонстрирует эту работу с трети уровнем наследования:
//: c06:Cartoon.java // Конструктор вызывается на стадии инициализации. class Art { Art() { System.out.println("Art constructor"); } } class Drawing extends Art { Drawing() { System.out.println("Drawing constructor"); } } public class Cartoon extends Drawing { Cartoon() { System.out.println("Cartoon constructor"); } public static void main(String[] args) { Cartoon x = new Cartoon(); } } ///:~
Вывод этой программы показывает автоматические вызовы:
Art constructor Drawing constructor Cartoon constructor
Как Вы можете видеть конструктор базового класса проинициализировал его до того, как к нему смог получить доступ произошедший от него класс.
Даже, если Вы не создаете конструктор для Cartoon( ), компилятор синтезирует конструктор по умолчанию для вызова конструктора базового класса.
Предыдущий пример имеет конструктор по умолчанию; и при этом он не имеет каких либо аргументов. Для компилятора такой вызов прост, нет ненужных вопросов по поводу аргументов, которые нужно передать. Если Ваш класс не имеет аргументов по умолчанию или если Вы хотите вызвать конструктор базового класса, который имеет аргументы, Вы должны просто использовать ключевое слово super и передать ему список аргументов:
//: c06:Chess.java // Наследование, конструкторы и аргументы. class Game { Game(int i) { System.out.println("Game constructor"); } } class BoardGame extends Game { BoardGame(int i) { super(i); System.out.println("BoardGame constructor"); } } public class Chess extends BoardGame { Chess() { super(11); System.out.println("Chess constructor"); } public static void main(String[] args) { Chess x = new Chess(); } } ///:~
Если же Вы не вызовите конструктор базового класса в BoardGame( ), тогда компилятор выдаст сообщение, что он не может найти конструктор для Game( ). В дополнение к вышесказанному - вызов конструктора базового класса должен быть осуществлен в первую очередь в конструкторе класса наследника. (Компилятор сообщит Вам об этом, если Вы сделали что-то не так.)
Как только что было замечено, компилятор предлагает Вам поместить конструктор базового класса в конструктор класса наследника. Это означает, что ничего другого не может произойти до его вызова. Как Вы увидите в главе 10, при этом нужно так же позаботится об обработке исключения пришедшего из конструктора базового класса.
Совместное использование композиции и наследования часто и широко используется при программировании. Следующий пример показывает создание более комплексного класса использующего оба метода и композицию, и наследование с необходимыми инициализациями конструкторов:
//: c06:PlaceSetting.java // Объединение композиции и наследования. class Plate { Plate(int i) { System.out.println("Plate constructor"); } } class DinnerPlate extends Plate { DinnerPlate(int i) { super(i); System.out.println( "DinnerPlate constructor"); } } class Utensil { Utensil(int i) { System.out.println("Utensil constructor"); } } class Spoon extends Utensil { Spoon(int i) { super(i); System.out.println("Spoon constructor"); } } class Fork extends Utensil { Fork(int i) { super(i); System.out.println("Fork constructor"); } } class Knife extends Utensil { Knife(int i) { super(i); System.out.println("Knife constructor"); } } // Нормальный путь, сделать что-то: class Custom { Custom(int i) { System.out.println("Custom constructor"); } } public class PlaceSetting extends Custom { Spoon sp; Fork frk; Knife kn; DinnerPlate pl; PlaceSetting(int i) { super(i + 1); sp = new Spoon(i + 2); frk = new Fork(i + 3); kn = new Knife(i + 4); pl = new DinnerPlate(i + 5); System.out.println( "PlaceSetting constructor"); } public static void main(String[] args) { PlaceSetting x = new PlaceSetting(9); } } ///:~
В то время, как компилятор требует от Вас инициализировать базовые классы и требует, что бы Вы делали это в начале конструктора, он не убеждается, в том, что Вы инициализировали остальные объекты, так что Вам придется быть осторожным.
Java не поддерживает концепцию C++ связанную с деструктором, специальным методом, который автоматически вызывается при уничтожении объекта. Причина этого в том, что в Java нужно просто забыть об объекте, позволяя тем самым освободить сборщику мусора память, если это необходимо .
Зачастую этот подход отлично работает, но иногда ваш класс может осуществлять некоторые действия во время его цикла жизни и требуется его очистить грамотно. Как уже упоминалось в главе 4, Вы не можете знать когда будет вызван сборщик мусора, и будет ли он вообще вызван. Так что, если Вы хотите очистить нечто в вашем классе, то Вам необходимо просто написать специальный метод выполняющий эту работу, и убедиться, что другой (возможный) программист знает, что он должен взывать этот метод. Эта проблема описана в главе 10 ("Обработка ошибок с помощью исключений"), Вы должны обработать исключение поместив некий очищающий код в блок finally.
Давайте рассмотрим пример вспомогательной компьютерной системы дизайна, которая рисует картинку на экране:
//: c06:CADSystem.java // Обеспечение правильной очистки. import java.util.*; class Shape { Shape(int i) { System.out.println("Shape constructor"); } void cleanup() { System.out.println("Shape cleanup"); } } class Circle extends Shape { Circle(int i) { super(i); System.out.println("Drawing a Circle"); } void cleanup() { System.out.println("Erasing a Circle"); super.cleanup(); } } class Triangle extends Shape { Triangle(int i) { super(i); System.out.println("Drawing a Triangle"); } void cleanup() { System.out.println("Erasing a Triangle"); super.cleanup(); } } class Line extends Shape { private int start, end; Line(int start, int end) { super(start); this.start = start; this.end = end; System.out.println("Drawing a Line: " + start + ", " + end); } void cleanup() { System.out.println("Erasing a Line: " + start + ", " + end); super.cleanup(); } } public class CADSystem extends Shape { private Circle c; private Triangle t; private Line[] lines = new Line[10]; CADSystem(int i) { super(i + 1); for(int j = 0; j < 10; j++) lines[j] = new Line(j, j*j); c = new Circle(1); t = new Triangle(1); System.out.println("Combined constructor"); } void cleanup() { System.out.println("CADSystem.cleanup()"); // Порядок очистки // обратен порядку инициализации t.cleanup(); c.cleanup(); for(int i = lines.length - 1; i >= 0; i--) lines[i].cleanup(); super.cleanup(); } public static void main(String[] args) { CADSystem x = new CADSystem(47); try { // Код и исключения обрабатываются... } finally { x.cleanup(); } } } ///:~
Все в этой системе является разновидностями шейпа (Shape) (который в свою очередь является разновидностью объекта(Object) в силу того, что он косвенным образом наследует корневой класс). Каждый класс переопределяет метод шейпа cleanup( ) в дополнении к этому еще и вызывает метод базового класса через использование super. Специфичные классы Shape, такие, как Circle, Triangle и Line все имеют конструкторы, которые рисуют, хотя любой метод, вызванный во время работы, должен быть доступным для чего либо нуждающегося в очистке. Каждый класс имеет свой собственный метод cleanup( ) для восстановления не использующих память вещей существовавших до создания объекта.
В методе main( ), Вы можете видеть два ключевых слова, которые для Вас новы, и не будут официально представлены до главы 10: try и finally. Ключевое слово try сигнализирует о начале блока (отделенного фигурными скобками), который является охраняемой областью, что означает, что он предоставляет специальную обработку при возникновении исключений. Одной из специальных обработок является порция кода заключенная в блок finally следующий за охраняемой областью и который всегда выполняется, вне зависимости от завершения блока try . (С обработкой исключений имеется возможность покинуть блок try бесчисленным количеством способов.) Здесь, finally означает:"Всегда вызывать cleanup( ) для x, без разницы, что случилось". Эти ключевые слова будут основательно разъяснены в главе 10.
Заметьте, что в Вашем методе очистки Вы должны так же быть внимательны в вызове очередности для базового класса и для вашего класса, в зависимости от отношений с подобъектом. В основном, Вы должны следовать тем же путем, как и в C++ в деструткорах: Сначала осуществляется очистка вашего класса в обратной последовательности создания. (В основном требуется, чтобы элементы базового класса были все еще доступны.) Затем вызвать метод очистки базового класса, как показано в примере.
Вообще может быть множество случаев, в которых очистка это не проблема, Вы просто позволяете сборщику мусора выполнить свою работу. Но, когда Вы должны очистку сделать самостоятельно, следует быть внимательным, усердным и осторожным.
Здесь не так уж и много уверенности, когда придет время сбора мусора . Сборщик мусора может быть так и ни разу не вызван. Если же он вызван, то он может освободить ресурсы от ненужных объектов, в каком ему заблагорассудится порядке. Поэтому лучше не рассчитывать полностью на сборщик мусора с полной очисткой памяти. Если вы хотите очистить для себя достаточно ресурсов - напишите свой собственной метод по очистке и не полагайтесь только на finalize( ). (Как уже упоминалось в главе 4, в Java можно принудительно вызвать все завершители.)
Только программисты C++ могут быть "обрадованы" скрытием имен, из-за того, что они работают по другому в этом языке (Java). Если базовый класс в Java имеет метод, который многократно перегружался, то при переопределении имени этого метода в классе потомке не будут скрыты методы в базовом классе. Поэтому перегрузка работает, не обращая,внимание на место определения метода, на этом уровне или в базовом классе:
//: c06:Hide.java // Перегрузка имени базового класса в дочернем, не скрывает метод базового класса. class Homer { char doh(char c) { System.out.println("doh(char)"); return 'd'; } float doh(float f) { System.out.println("doh(float)"); return 1.0f; } } class Milhouse {} class Bart extends Homer { void doh(Milhouse m) {} } class Hide { public static void main(String[] args) { Bart b = new Bart(); b.doh(1); // doh(float) использован b.doh('x'); b.doh(1.0f); b.doh(new Milhouse()); } } ///:~
Как Вы увидите в следующей главе, такой подход далек от наиболее частого использования при переопределении методов. Нужно использовать метод с тем же именем используя практически ту же сигнатуру и возвращая тот же тип в базовом классе. Если сделать по другому, то это уже не сработает (поэтому то C++ и запрещает такой прием, что бы ограничить программиста в создании новой возможной ошибки).
Оба метода, композиция и наследование, позволяют Вам поместить подобъект внутрь вашего нового класса. Вы можете быть изумлены различием между ними двумя и при этом метаться при выборе одного способа перед другим.
Композиция в основном используется когда Вам нужно использовать возможности существующего класса, но не использовать его интерфейс. Это значит, что Вы внедряете объект, так, что вы можете использовать его для получения доступа к функциональности внедряемного объекта в вашем новом классе, но пользователь вашего нового класса видит интерфейс вашего нового класса раньше, чем интерфейс внедряемого объекта. Что бы добиться такого эффекта, Вы должны включать private объекты существующих классов внутрь вашего нового класса.
Иногда требуется разрешить пользователю класса получить доступ к вашему новому классу напрямую; что бы сделать это, нужно сделать объекты public. Эти объекты используют реализацию скрытия самих себя, так что такой подход достаточно безопасен. Если пользователь знает, что Вы собрали этот класс из различных частей, то интерфейс этого класса будет для него более легок в понимании. Объект car хороший пример, иллюстрирующий данную технологию:
//: c06:Car.java // Композиция с public объектами. class Engine { public void start() {} public void rev() {} public void stop() {} } class Wheel { public void inflate(int psi) {} } class Window { public void rollup() {} public void rolldown() {} } class Door { public Window window = new Window(); public void open() {} public void close() {} } public class Car { public Engine engine = new Engine(); public Wheel[] wheel = new Wheel[4]; public Door left = new Door(), right = new Door(); // 2-door public Car() { for(int i = 0; i < 4; i++) wheel[i] = new Wheel(); } public static void main(String[] args) { Car car = new Car(); car.left.window.rollup(); car.wheel[0].inflate(72); } } ///:~
В силу того, что состав класса car является частью анализа проблемы (а не просто часть основного приема программирования), создание членов классов public поможет программисту понять, как использовать класс и требует меньше кода и меньшей запутанности для создания класса. Но все равно, помните, что этот прием только для специальных случаев, и в основном вы должны делать поля private.
При наследовании, Вы берете существующий класс и создаете специальную его версию. В основном это означает, что Вы берете главный, целевой класс и приспосабливаете его для частных нужд. Немного поразмыслив, Вы увидите, что нет разницы при создании класса car используя объект vehicle - car не содержит vehicle, он и есть vehicle. Отсюда связь он и есть используется в наследовании, а содержит при композиции.
Итак вы только что получили представление о наследовании и теперь пришло время раскрыть смысл ключевого слова protected. В идеальном мире, private объекты всегда являются действительно private, но в реальных проектах, где вы пытаетесь во многих местах скрыть от внешнего мира нечто, Вам часто нужна возможность получить к нему доступ из классов наследников. Ключевое слово protected поэтому не такая уж и ненужная назойливость или догма. Оно объявляет "Этот объект частный (private), если к нему пытается подобраться пользователь, но он доступен для всех остальных находящихся в том же самом пакете(package)". То есть , protected в Java автоматически означает friendly.
Наилучшим решением при этом оставить данным модификатор private, но с другой стороны для доступа к ним оставить protected методы:
//: c06:Orc.java // Ключевое слово protected. import java.util.*; class Villain { private int i; protected int read() { return i; } protected void set(int ii) { i = ii; } public Villain(int ii) { i = ii; } public int value(int m) { return m*i; } } public class Orc extends Villain { private int j; public Orc(int jj) { super(jj); j = jj; } public void change(int x) { set(x); } } ///:~
Вы можете видеть, что change( ) имеет доступ к set( ) потому, что он protected.
Одним из преимуществ наследования является поддержка инкрементной разработки, при помощи которой Вы можете создавать новый код, без внесения возможных ошибок в уже существующий. При этом новые ошибки так же остаются внутри нового кода. Наследуя из существующего, функционального класса и добавляя методы и поля данных, а так же переопределяя существующие методы, Вы оставляете в первозданном виде уже существующий код, тем самым кто-то сможет воспользоваться им нетронутым и без новых ошибок. Если же вдруг случится ошибка, Вы будете знать, что она в вашем коде, и при этом ее найти будет проще и быстрее, чем если бы Вы модифицировали уже существующий и отлаженный код.
То, как чисто разделяются классы может показаться удивительно. Вам не нужен исходный код, поскольку вы можете использовать технологию повторного использования исходного кода. Самое большое, что вам нужно сделать, это импортировать пакет. И это справедливо и для наследования и для композиции.
Важно понимать, что инкрементальная разработка программы всего лишь процесс, похожий на обучение человека. Вы можете анализировать вашу будущую программу сколько угодно, но все равно останутся вопросы которые возникнут только в процессе разработки проекта. Ваш проект будет более удачлив и более гибким, если Вы будете растить его как органическую структуру, как эволюционирующее создание, по сравнению, если бы Вы начали создавать его как единообразной квадратно-стеклянный небоскреб, пытаясь учесть в нем все нюансы.
Несмотря на то, что наследование для экспериментов может быть просто превосходной техникой, однако после некоторой точки стабилизации Вам необходимо окинуть взором вашу иерархию классов и привести ее в разумные размеры. Помните, что наследование определяет связь - Этот новый класс такого же типа, как и тот старый. Ваша программа не должен разбрасываться битами вокруг, а вместо этого создавать и манипулировать объектами многих типов для выражения модели в терминах проблемной области.
Наиболее важный аспект наследования заключается вовсе не в снабжении нового класса новыми методами. А заключается он в отношении между новым классом и базовым классом. Данное отношение можно определить так "Новый класс имеет тип существующего класса."
Это описание, не просто причудливая форма раскрытия сущности наследования, такая форма поддерживается напрямую языком Java. В примере, рассматриваемый базовый класс называется Instrument и представляет музыкальные инструменты, а дочерний класс называется Wind (духовые инструменты). Поскольку наследование подразумевает, что все методы в базовом классе так же доступны и в дочернем классе, то любое сообщение, которое может быть послано базовому классу, так же доступно и в дочернем. Если класс Instrument имеет метод play( ), то и Wind так же может его использовать. Это означает, что мы можем точно так же сказать, что объект Wind так же и типа Instrument. Следующий пример показывает, как компилятор поддерживает это высказывание:
//: c06:Wind.java // Наследование и приведение к базовому типу. import java.util.*; class Instrument { public void play() {} static void tune(Instrument i) { // ... i.play(); } } // Объект Wind так же Instrument // потому что они имеют общий интерфейс: class Wind extends Instrument { public static void main(String[] args) { Wind flute = new Wind(); Instrument.tune(flute); // Upcasting } } ///:~
Что действительно интересно в этом примере, так это то, что метод tune( ) поддерживает ссылку на Instrument. Однако, в Wind.main( ) метод tune( ) вызывается с передачей ссылки на Wind. Из этого следует, что Java специфична с проверкой типов, это выглядит достаточно странно, если метод принимающий в качестве параметра один тип, вдруг спокойно принимает другой, но так пока вы не поймете, что объект Wind так же является и объектом типа Instrument, и в нем нет метода tune( ) который можно было бы вызвать для Instrument. Внутри tune( ), код работает с типами Instrument и с чем угодно от него произошедшим, а факт конвертации ссылки на Wind в ссылку на Instrument называется приведением к базовому типу (upcasting).
Причина этого термина кроется в недрах истории, и основана она диаграмме наследования классов имеющую традиционное начертание: сверху страницы корень, растущий вниз. Естественно, Вы можете нарисовать свою собственную диаграмму, каким угодно образом. Диаграмма наследования для Wind.java:
Преобразование (casting) дочернего к базовому происходит при движении вверх (up) по диаграмме наследования, так что получается - upcasting (приведение к базовому типу). Приведение к базовому типу всегда безопасно, поскольку Вы переходите от более общего типа, к более конкретному. Так что дочерний класс является супермножеством базового класса. Он может содержать больше методов, чем базовый класс, но он должен содержать минимум все те методы, что есть в базовом классе. Только одна вещь может случится при приведении к базовому типу, это, что могут потеряться некоторые методы. Вот по этому то компилятор и позволяет осуществлять приведение к базовому типу без каких либо ограничений на приведение типов или специальных замечаний.
Вы так же можете осуществить обратную приведению к базовому типу операцию, называемую приведение базового типа к дочернему (downcasting), но при этом возникает небольшая дилемма, которая разъяснена в главе 12.
В объектно-ориентированном программировании наиболее используемый способ заключается в создании и использовании кода с хранением и кода и данных в одном пакете-классе. Вы так же используете существующие классы для создания новых классов при помощи композиции. Менее часто используется наследование. Однако, наследование более выразительно при изучении ООП, но это вовсе не значит, что его нужно использовать, где только возможно. Тем не менее, Вы должны использовать наследование, там, где его использование полезно. Один из понятных путей для определения, что Вы должны использовать, наследование или композицию заключается в выяснении нужно ли будет вам приводить что-то к базовому типу или нет. Если вам необходимо приведение к базовому типу, то наследование просто необходимо, но если же Вы не нуждаетесь в этом, то стоит присмотреться, а так ли уж необходимо здесь наследование. Следующая глава (полиморфизм) предоставляет одну из наиболее непреодолимых причин для приведения к базовому типу, но если Вы вспомните вопрос "Нужно ли мне приведение к базовому типу?", то Вы получите хороший способ для решения при выборе между композицией или наследованием.
В Java ключевое слово final имеет слегка разные значения в зависимости от контекста, но в основном, оно определяется так "Это не может быть изменено". Вы можете хотеть запретить изменения по двум причинам: дизайн или эффективность. Поскольку эти две причины слегка различаются, то существует возможность неправильного употребления ключевого слова final.
В следующих секциях обсуждается применение final тремя способами: для данных, для методов и для классов.
Многие языки программирования имеют пути сообщить компилятору, что данный кусочек данных является неизменным, константой. Константа наиболее удобна для применения в следующих двух случаях:
В случае константы во время компиляции компилятор свертывает константу до значения в любых вычислениях, где она используется; при этом, нагрузка при вычислениях во время работы программы может быть значительно снижена. В Java константы такого рода должны быть примитивного типа и объявлены с использованием final. Значение должно быть определено во время определения переменной, как и любой константы.
Поля имеющие модификаторы static и final вообще являются ячейкой для хранения и не могут быть изменены.
При использовании final с объектами, а не с примитивными типами получается несколько не тот эффект. С примитивами, final создает константу значения, а с объектами - ссылку, final создает ссылку - константу. Как только ссылка инициализируется на какой-то объект, она уже не может быть в последствии перенаправлена на другой объект. Однако сам объект может быть модифицирован; Java не предоставляет способа создать объект - константу. (Однако, Вы можете написать свой собственный класс с эффектом константы.) Эти же ограничения накладываются и на массивы, поскольку они тоже объекты.
Ниже представлен пример, демонстрирующий использование полей с модификатором final:
//: c06:FinalData.java // Эффект полей final. class Value { int i = 1; } public class FinalData { // Может быть константой во время компиляции final int i1 = 9; static final int VAL_TWO = 99; // Обычная public константы: public static final int VAL_THREE = 39; // Не может быть константой во время компиляции: final int i4 = (int)(Math.random()*20); static final int i5 = (int)(Math.random()*20); Value v1 = new Value(); final Value v2 = new Value(); static final Value v3 = new Value(); // Массивы: final int[] a = { 1, 2, 3, 4, 5, 6 }; public void print(String id) { System.out.println( id + ": " + "i4 = " + i4 + ", i5 = " + i5); } public static void main(String[] args) { FinalData fd1 = new FinalData(); //! fd1.i1++; // Ошибка: значение не может быть изменено fd1.v2.i++; // Объект не константа! fd1.v1 = new Value(); // OK -- не final for(int i = 0; i < fd1.a.length; i++) fd1.a[i]++; // Объект не константа! //! fd1.v2 = new Value(); // Ошибка: Нельзя //! fd1.v3 = new Value(); // изменить ссылку //! fd1.a = new int[3]; fd1.print("fd1"); System.out.println("Creating new FinalData"); FinalData fd2 = new FinalData(); fd1.print("fd1"); fd2.print("fd2"); } } ///:~
Поскольку i1 и VAL_TWO являются final примитивами со значениями во время компиляции, то они могут быть использованы в обоих случаях, как константы времени компиляции и не имеют при этом отличий. VAL_THREE определена более типичным путем и Вы можете видеть, как определяются константы: public так, что она может быть использована вне пакета, static т.е. может существовать только одна и final объявляет, что она и есть константа. Заметьте, что примитивы final static с начальными неизменяемыми значениями (константы времени компилирования) называются большими буквами и слова разделены подчеркиванием (такие наименование похожи на константы в C.) Так же заметьте, что i5 не может быть известна во время компиляции, поэтому она названа маленькими буквами.
Просто, если, что-то определено, как final это еще не значит, что его значение известно на стадии компиляции. Это утверждение демонстрируется инициализацией i4 и i5 во время выполнения с использованием случайно генерируемых чисел. Та порция примера так же показывает различие между созданием final с модификатором static и без него. Это различие заметно, только во время инициализации во время выполнения, это происходит из-за того, что значения времени компиляции обращаются в те же самые самим компилятором. (И по видимому, существенно оптимизированными.) Это различие показано в выводе программы после одного запуска:
fd1: i4 = 15, i5 = 9 Creating new FinalData fd1: i4 = 15, i5 = 9 fd2: i4 = 10, i5 = 9
Заметьте, что значения i4 для fd1 и для fd2 уникальны, но значение для i5 не изменилось после создания второго объекта FinalData. Такое произошло потому, что i5 static и инициализировалась только один раз при загрузке, а не каждый раз, когда создавался новый объект.
Переменные v1 и v4 демонстрируют значение final ссылки. Как Вы можете видеть в методе main( ), только потому, что v2 является final вовсе не означает, что Вы не можете изменить ее значение. Хотя, Вы не можете перенаправить v2 на новый объект, и это потому, что она final. Вот такой смысл вкладывается в понятие final ссылок. Вы так же можете увидеть точно такое же действие на примере массивов, поскольку они являются так же разновидностью ссылок. (Я например не знаю способа, как сделать ссылки массивов самих на себя final.) Таким образом, создание ссылок с типом final менее удобна в использовании, чем создание примитивов с модификатором final.
Java позволяет создавать пустые (чистые) final объекты (blank final), это такие поля данных, которые были объявлены как final но при этом не были инициализированы значением. Во всех случаях, пустая final переменная должна быть инициализирована до ее использования и компилятор обеспечивает это условие. Тем не менее, пустые final поля предоставляют большую гибкость при использовании модификатора final, к примеру, final поле внутри класса может быть разным для каждой копии объекта. Вот пример:
//: c06:BlankFinal.java // "Пустые" final данные. class Poppet { } class BlankFinal { final int i = 0; // инициализируем final final int j; // пустой final final Poppet p; // Ссылка на пустой final // Пустой final ДОЛЖЕН быть инициализирован // в конструкторе: BlankFinal() { j = 1; // инициализируем чистую final p = new Poppet(); } BlankFinal(int x) { j = x; // Инициализируем чистую final p = new Poppet(); } public static void main(String[] args) { BlankFinal bf = new BlankFinal(); } } ///:~
Вы принудительно должны осуществить соединение переменной final со значением при ее определении или в конструкторе. При этом гарантировано не будет доступа к переменной, до ее инициализации.
Java позволяет Вам так же создавать и аргументы final определением их таким образом прямо в списке аргументов. Это означает, что внутри метода Вы не сможете изменить этот аргумент или его ссылку:
//: c06:FinalArguments.java // Использование "final" с аргументами методов. class Gizmo { public void spin() {} } public class FinalArguments { void with(final Gizmo g) { //! g = new Gizmo(); // Неверно -- g - final } void without(Gizmo g) { g = new Gizmo(); // OK -- g не final g.spin(); } // void f(final int i) { i++; } // Не может измениться // Вы можете только читать примитив: int g(final int i) { return i + 1; } public static void main(String[] args) { FinalArguments bf = new FinalArguments(); bf.without(null); bf.with(null); } } ///:~
Заметьте, что Вы все еще можете соединить null ссылку с final аргументом, без реакции со стороны компилера, таким же образом, как и с не final аргументами.
Методы f( ) и g( ) показывают, что случается, когда примитивный аргумент - final: Вы можете прочитать его, но не можете изменить его.
Существует две причины для final методов. Первая - закрытие методов, от возможной модификации при наследовании класса. Такой подход применяется если Вы хотите быть уверенны, что этот метод не будет переопределен в дочерних классах и поведение класса не изменится.
Вторая причина - final методы более эффективны. Если Вы делаете метод с модификатором final, Вы тем самым разрешаете компилятору все вызовы этого метода превратить во внутренние (inline) вызовы. Когда компилятор видит final метод он может(на свое усмотрение) пропустить нормальный метод добавления кода, т.е. обычного исполнения метода (поместить аргументы в стек, перепрыгнуть на код метода и выполнить его, перепрыгнуть обратно на стек аргументов и очистить их, разобраться с возвращаемым значением) и заменить вызов метода на копию актуального кода из тела метода. При этом снижается загрузка машины при выполнении вызова метода. Естественно, если ваш метод велик, тогда ваш код распухнет и вероятно Вы не увидите никаких преимуществ по производительности от использования прямых вызовов, поскольку все повышения производительности при вызове будут съедены временем выполнения кода внутри самого метода. Поэтому Java компилятор способен определять такие ситуации и решать, когда осуществлять компиляцию final метода во внутренние вызовы. Но, все таки не следует слишком уж доверять компилятору и создавать final методы, только, если они действительно небольшие и Вы действительно хотите запретить их изменение при наследовании.
Любой private метод косвенным образом final. Поскольку Вы не можете получить доступ к private методу, Вы не можете переопределить его (даже если компилятор не выдаст сообщения об ошибке при переопределении, Вы все равно не сможете переопределить его, Вы просто создадите новый метод). Вы можете добавить спецификатор final к private методу, но это не добавит ему никаких дополнительных возможностей.
Эта особенность может создать неудобство, поскольку если Вы попытаетесь перекрыть private метод (который косвенно и final) то оно будет работать:
//: c06:FinalOverridingIllusion.java // Это только выглядит так, как буд-то вы перекрыли // private или private final метод. class WithFinals { // Идентично "private": private final void f() { System.out.println("WithFinals.f()"); } // Так же автоматически "final": private void g() { System.out.println("WithFinals.g()"); } } class OverridingPrivate extends WithFinals { private final void f() { System.out.println("OverridingPrivate.f()"); } private void g() { System.out.println("OverridingPrivate.g()"); } } class OverridingPrivate2 extends OverridingPrivate { public final void f() { System.out.println("OverridingPrivate2.f()"); } public void g() { System.out.println("OverridingPrivate2.g()"); } } public class FinalOverridingIllusion { public static void main(String[] args) { OverridingPrivate2 op2 = new OverridingPrivate2(); op2.f(); op2.g(); // Вы можете привести к базовому типу: OverridingPrivate op = op2; // Но Вы не можете вызвать методы: //! op.f(); //! op.g(); // Так же здесь: WithFinals wf = op2; //! wf.f(); //! wf.g(); } } ///:~
"Переопределение" может быть использовано только если это что-то является частью интерфейса базового класса. То есть, Вы должны быть способны привести метод к базовому типу объекта и вызвать тот же самый метод (как это делается будет объяснено в следующей главе). Если же метод private, то он не является частью интерфейса базового класса. Он это просто немного кода скрытого внутри класса, и просто так случилось, что он имеет то же имя, но если уж Вы создаете метод с модификатором public, protected или friendly в дочернем классе, то здесь нет связи с методом из базового класса. Поэтому private метод недоступный и эффективный способ скрыть какой-либо код, и при этом он не влияет на организацию кода в котором он был объявлен.
Когда Вы объявляете целый класс final (путем добавления в его определение ключевого слова final), Вы тем самым заявляете, что не хотите наследовать от этого класса или что бы кто-то другой мог наследовать от него. Другими словами, по некоторым причинам в вашем классе не должны делаться какие-либо изменения, или по причинам безопасности не могут быть созданы подклассы. В другом же случае, причиной сделать класс final может послужить эффективность выполнения кода класса, но здесь нужно быть уверенным, что все, что внутри класса уже оптимизировано как можно более максимально.
//: c06:Jurassic.java // Создание целого final класса. class SmallBrain {} final class Dinosaur { int i = 7; int j = 1; SmallBrain x = new SmallBrain(); void f() {} } //! класс Further расширяет Dinosaur {} // Ошибка: Нельзя расширить класс 'Dinosaur' public class Jurassic { public static void main(String[] args) { Dinosaur n = new Dinosaur(); n.f(); n.i = 40; n.j++; } } ///:~
Заметьте, что поля данных могут быть final, а могут и не быть, по вашему выбору. Те же самые правила применимы и к final членам класса вне зависимости определен ли сам класс, как final. Определение класса, как final просто предотвращает дальнейшее от него наследование. Несмотря на это, поскольку он предотвращает наследование всех методов в классе final, то они безоговорочно тоже становятся final, поскольку нет способа для их переопределения. Так что компилятор имеет тоже полезное действие, как если бы Вы определили каждый из методов как final.
Вы можете добавить спецификатор final к методу в final классе, но это уже ничего означать не будет.
Может показаться, что создание final метода во время разработки класса хорошая идея. Вы можете чувствовать, что эффективность и важность его высоки и никто не должен перекрыть этот метод. Иногда это действительно так.
Но будьте осторожны в своих предположениях. Обычно трудно предположить, как именно будет в дальнейшем этот класс использоваться, особенно в качестве основного-целевого класса. Если Вы определили метод как final Вы можете предотвратить повторное его использование через наследование в других проектах, других программистов и просто потому, что Вы не можете себе представить, каким образом они будут это делать.
Хорошим примером для этого может послужить стандартная библиотека Java. В частности, Java 1.0/1.1 класс Vector был часто использован и мог бы быть еще больше удобным в применении, если бы в иерархии все его методы, не бы ли бы сделаны final. Его изменение бы легко сделать через наследование и переопределение методов, но разработчики сочли это не подходящим. И здесь зарыта ирония двух причин. Первая, Stack наследуется от Vector, отсюда Stack является Vector, что с точки зрения логики не совсем правда. Вторая, многие из наиболее важных методов класса Vector, такие как addElement( ) и elementAt( ) являются синхронизированными (synchronized). Как Вы увидите в главе 14, при этом система подвергается значительным перегрузкам, которые возможно могут свести на нет все возможности предоставляемые final. Такая неувязочка подтверждает легенду о том, что программисты плохо себе представляют, в каком именно месте должна быть произведена оптимизация. Это просто плохой и неуклюжий дизайн воплощенный в двух стандартных библиотеках, которыми мы все должны пользоваться. (Хорошо, то, что в Java 2 контейнерная библиотека заменила Vector на ArrayList, который ведет себя более прилично. Плохо то, что осталось множество программного обеспечения уже написанного с использованием старой контейнерной библиотеки.)
Так же следует заметить, что другая не менее интересная библиотека Hashtable, не имеет ни одного метода с модификатором final. Как уже упоминалось в этой книге - различные классы написаны различными людьми и из-за этого встречаются такие похожие и не похожие библиотеки. А вот это уже не должно волновать потребителей классов. Если такие вещи противоречивы, то это лишь добавляет работы пользователям. Еще одна победная песнь грамотному дизайну и созданию кода. (Заметьте, что в Java 2 контейнерная библиотека заменила Hashtable на HashMap.)
В большинстве традиционных языках программирования, программы загружаются как единая часть при запуске. После этого следует стадия инициализации, и после нее программа начинает работать. Процесс инициализации в этих языках должен с осторожностью контролироваться, потому, что последовательность инициализации static "объектов" может вызвать проблемы. C++, к примеру, имеет проблемы, если один из static "объектов" ожидает другой static "объект", но до того, как второй был проинициализирован.
Java же не имеет такой проблемы, поскольку в ней используется отличный подход по загрузке файлов. Поскольку в Java все является объектами, то многие виды деятельности более просты в исполнении, и загрузка входит в их число. Как Вы узнаете в следующей главе, скомпилированный код для каждого класса существует в своем собственном отдельном файле. Эти файлы не загружаются, до того момента, как понадобится код хранящийся в них. Обычно, Вы можете сказать: "Код классов загружается при первой попытке их использования." Но загрузка так же осуществляется часто до того, как первый класс был загружен и проинициализирован полностью, и загрузка других классов случается, когда осуществляется доступ к static полям или static методам.
Точка первого использования другого класса находится там же, где и инициализация static объекта. Все static объекты и весь static блок кода будет инициализирован в текстовом порядке (это значит, что они будут инициализироваться в том порядке, в каком Вы их написали в определении класса) при загрузке. Static объекты, естественно инициализируются только один раз.
Полезно взглянуть на процесс инициализации целиком, включая наследование, что бы получить полную картину того, что же происходит на самом деле. Рассмотрим следующий код:
//: c06:Beetle.java // Полный процесс инициализации. class Insect { int i = 9; int j; Insect() { prt("i = " + i + ", j = " + j); j = 39; } static int x1 = prt("static Insect.x1 initialized"); static int prt(String s) { System.out.println(s); return 47; } } public class Beetle extends Insect { int k = prt("Beetle.k initialized"); Beetle() { prt("k = " + k); prt("j = " + j); } static int x2 = prt("static Beetle.x2 initialized"); public static void main(String[] args) { prt("Beetle constructor"); Beetle b = new Beetle(); } } ///:~
Вот вывод программы:
static Insect.x1 initialized static Beetle.x2 initialized Beetle constructor i = 9, j = 0 Beetle.k initialized k = 47 j = 39
Первая вещь, которая происходит, когда Вы запускаете Java и Beetle это то, что Вы пытаетесь получить доступ к Beetle.main( ) (к static методу), так, что загрузчик пытается найти и открыть скомпилированный код к классу Beetle (он нашел его в файле Beetle.class). В процессе его загрузки компилятор обнаруживает, что этот класс имеет базовый класс (об этом ему сообщает ключевое слово extends), оный он и загружает в последствии. Случилось ли это или нет, Вы собираетесь создать объект базового класса. (Попробуйте закомментировать создание объекта, для того, что бы сделать это самостоятельно.)
Если базовый класс имеет так же базовый класс, то этот второй базовый класс будет загружен и так далее. Дальше, производится инициализация static элементов в корневом классе (в нашем случае в классе Insect), а только затем в дочернем и так далее. И это важно, поскольку static элементы дочернего класса могут быть зависимы от членов базового класса, которые могут не быть проинициализированы корректно к этому времени.
Теперь все нужные классы уже загружены, так что наш объект может быть создан. Сперва, все примитивные элементы нашего объекта устанавливаются в их значения по умолчанию, а ссылки на объекты устанавливаются в null, в "железе" просто устанавливаются ячейки памяти в двоичный ноль. После этого вызывается конструктор базового класса. При этом вызов осуществляется автоматически, но Вы можете так же выбрать какой-то конкретный конструктор базового класса (первый оператор в конструкторе Beetle( )) используя super. Создание базового класса происходит по тем же правилам и в том же порядке, что и создание дочернего класса. После того, как отработает конструктор базового класса все представления переменных уже будут проинициализированы в текстовом порядке.
Оба метода, и наследование и композиция позволяют Вам создать новый тип из уже существующего. Обычно, Вы используете композицию для повторного использования существующих типов как части имплементации нового типа, а наследование когда Вам необходимо повторно использовать интерфейс. В силу того, что дочерний класс имеет интерфейс базового класса, он может быть приведен к базовому типу, что весьма важно для полиморфизма, что собственно Вы и увидите в следующей главе.
Относитесь с осторожностью к наследованию в объектно-ориентированном программировании, когда Вы начинаете разработку нового проекта используйте лучше композицию, а после того, как ваш код будет доведен до совершенства измените композицию на наследование если это конечно необходимо. Композиция имеет тенденцию к большей гибкости. Вы так же можете изменять поведение объектов с композицией во время исполнения.
Повторное использование композиции и наследования оказывает огромную помощь для быстрой разработки проектов, Вы обычно хотите изменить иерархию ваших классов, до того, как другие программисты станут работать с вашим проектом и с вашими классами. Вашим преимуществом при этом будет наследование, где каждый класс не большой по размеру (но и не слишком маленький, что бы не потерять функциональность) и выполняет узкую задачу.
Решения этих упражнений могут быть найдены в электронном документе The Thinking in Java Annotated Solution Guide, доступном с www.BruceEckel.com.