→ Пошук по сайту       Увійти / Зареєструватися
Знання Програмування на Java Навчальний курс по JAVA

Оголошення класів

 Java

Введення

Оголошення класів є центральною темою курсу, оскільки будь-яка програма на Java - це набір класів. Оскільки типи є ключовою конструкцією мови, їх структура досить складна, має багато тонкощів. Тому дана тема розділена на дві лекції.

Ця лекція починається з продовження теми минулій лекції - імена і доступ до іменованих елементів мови. Необхідно розглянути механізм розмежування доступу в Java, як він улаштований, для чого застосовується. Потім будуть описані ключові правила оголошення класів.

Модифікатори доступу

У багатьох мовах існують права доступу, які обмежують можливість використання, наприклад, змінної в класі. Наприклад, легко уявити два крайніх виду прав доступу: це public, коли поле доступнез будь-якої точки програми, і private, коли поле може використовуватися тільки усередині того класу, в якому воно оголошено.

Однак перш, ніж переходити до детального розгляду цих та інших модифікаторів доступу, необхідно уважно розібратися, навіщо вони взагалі потрібні.

Призначення модифікаторів доступу

Дуже часто права доступу розцінюються як якийсь елемент безпеки коду: мовляв, необхідно захищати класи від "неправильного" використання. Наприклад, якщо в класі Human (чоловік) є поле age (вік людини), то який-небудь програміст навмисно або через незнання може присвоїти цьому полю від'ємне значення, після чого об'єкт стане працювати неправильно, можуть з'явитися помилки. Для захисту такого поля age необхідно оголосити його private.

Це досить поширена точка зору, проте треба визнати, що вона далека від істини. Основним змістом розмежування прав доступу є забезпечення невід'ємного властивості об'єктної моделі - інкапсуляції, тобто приховування реалізації. Виправимо приклад таким чином, щоб він коректно відображав призначення модифікаторів доступу. Отже, нехай у класі Human є поле age цілочисельного типу, і щоб всі бажаючі могли користуватися цим полем, воно оголошується public.

 public class Human {
   public int age;
}

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

Може вийти так, що цілочисельного типу даних буде вже недостатньо і захочеться змінити тип поля на дробовий. Проте, якщо просто змінити int на double, незабаром всі розробники, які користувалися класом Human і його полем age, виявлять, що в їх коді з'явилися помилки, тому що поле раптом стало дробовим, і в рядках, подібних цим:

 Human h = getHuman (); //  отримуємо посилання
int i = h.age; // помилка!

буде виникати помилка через спробу провести неявним чином звуження примітивного типу.

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

Навпаки, якщо б поле було оголошено як private, а для читання і зміни його значення були б введені додаткові методи, ситуація змінилася б в корені:

 public class Human {
   private int age;
   // Метод, який повертає значення age
   public int getAge () {
      return age;
   }
   // Метод, який встановлює значення age
   public void setAge (int a) {
      age = a;
   }
}

У цьому випадку з даним класом могло б працювати безліч програмістів і могло бути створена велика кількість класів, які використовують тип Human, але модифікатор private дає гарантію, що ніхто напряму цим полем не користується і зміна його типу була б зовсім нескладною операцією, пов'язаною зі зміною тільки в одному класі.

Отримання величини віку виглядало би наступним чином:

 Human h = getHuman ();
int i = h.getAge (); // звернення через метод

Розглянемо, як виглядає процес зміни типу поля age:

 public class Human {
   // Поле отримує новий тип double
   private / * int * / double age;
   // Старі методи працюють з округленням
   // Значення
   public int getAge () {
      return (int) Math.round (age);
   }
   public void setAge (int a) {
      age = a;
   }
   // Додаються нові методи для роботи
   // З типом double
     public double getExactAge () {
      return age;
   }
   public void setExactAge (double a) {
   age = a;
   }
}

Видно, що старі методи, які, можливо, вже застосовуються в багатьох місцях, залишилися без зміни. Точніше, залишився без змін їх зовнішній формат, а внутрішня реалізація ускладнилася. Але така зміна не вимагатиме жодних модифікацій решти класів системи. Приклад використання

 Human h = getHuman ();
int i = h.getAge (); // коректно

залишається вірним, змінна i отримує коректне ціле значення. Проте зміни вводилися для того, щоб можна було працювати з дробовими величинами. Для цього були додані нові методи і у всіх місцях, де потрібне точне значення віку, необхідно звертатися до них:

 Human h = getHuman ();
double d = h.getExactAge ();
   // Точне значення віку

Отже, в клас була додана нова можливість, яка не вимагала ніяких змін коду.

За рахунок чого була досягнута така гнучкість? Необхідно виділити властивості об'єкта, які будуть потрібні майбутнім користувачам цього класу, і зробити їх доступними (в даному випадку, public). Ті ж елементи класу, що містять деталі внутрішньої реалізації логіки класу, бажано приховувати, щоб не утворилися небажані залежності, які можуть стримувати розвиток системи.

Цей приклад одночасно ілюструє і інше теоретичне правило написання об'єктів, а саме: у більшості випадків доступ до полів краще реалізовувати через спеціальні методи (accessors) для читання (getters) та запису (setters). Тобто саме поле розглядається як деталь внутрішньої реалізації. Дійсно, якщо розглядати зовнішній інтерфейс об'єкту який цілком складається з допустимих дій, то доступними елементами повинні бути тільки методи, що реалізують ці дії. Один з випадків, в якому такий підхід приносить необхідну гнучкість, вже розглянуто.

Є й інші міркування. Наприклад, повернемося до питання про коректне використання об'єкта та встановлення вірних значень полів. Як наслідок, правильне розмежування доступу дозволяє запровадити механізми перевірки вхідних значень:

 public void setAge (int a) {
   if (a> = 0) {
      age = a;
   }
} 

У цьому прикладі поле age ніколи не прийме некоректне негативне значення. (Недоліком наведеного прикладу є те, що у разі неправильних вхідних даних вони просто ігноруються, немає ніяких повідомлень, що дозволяють дізнатися, що зміни поля віку насправді не відбулося; для повноцінної реалізації методу необхідно освоїти роботу з помилками в Java.)

Бувають і більш суттєві зміни логіки класу. Наприклад, дані можна почати зберігати не в полях класу, а в більш надійний сховок, наприклад, файлової системи або базі даних. У цьому випадку методи-аксессори знову змінять свою реалізацію і почнуть звертатися до persistent storage (постійне сховище, наприклад, БД) для читання / запису значень. Якщо доступу до полів класу не було, а відкритими були тільки методи для роботи з їх значеннями, то можна змінити код цих методів, а зовнішні типи, які використовували даний клас, абсолютно не зміняться, логіка їх роботи залишиться тією ж.

Підіб'ємо підсумки. Функціональність класу необхідно розділяти на відкритий інтерфейс, що описує дії, які будуть використовувати зовнішні типи, і на внутрішню реалізацію, яка застосовується тільки всередині самого класу. Зовнішній інтерфейс надалі модифікувати неможливо, або дуже складно, для великих систем, тому його потрібно продумувати особливо ретельно. Деталі внутрішньої реалізації можуть бути змінені на будь-якому етапі, якщо вони не змінюють логіку роботи всього класу. Завдяки такому підходу реалізується одна з базових характеристик об'єктної моделі - інкапсуляція, і забезпечується важлива перевага технології ООП - модульність.

Таким чином, модифікатори доступу вводяться не для захисту типу від зовнішнього користувача, а, навпаки, для захисту, або позбавлення, користувача від зайвих залежностей від деталей внутрішньої реалізації. Що ж стосується неправильного застосування класу, то його творцям потрібно прагнути до того, щоб клас був простий у застосуванні, тоді таких проблем не виникне, адже програміст не стане навмисно писати код, який породжує помилки в його програмі.

Звичайно, таке розбиття на зовнішній інтерфейс і внутрішню реалізацію не завжди очевидно, часто умовно. Для полегшення завдання технічних дизайнерів класів в Java введено не два (public і private), а чотири рівня доступу. Розглянемо їх і весь механізм розмежування доступу в Java більш докладно.

Розмежування доступу в Java

Рівень доступу елемента мови є статичним властивістю, задається на рівні коду і завжди перевіряється під час компіляції. Спроба звернутися до закритого елементу безпосередньо викличе помилку.

У Java модифікатори доступу вказуються для:

  • типів (класів та інтерфейсів) оголошення верхнього рівня;
  • елементів вказівних типів (полів, методів, внутрішніх типів);
  • конструкторів класів.

Як наслідок, масив також може бути недоступний у тому випадку, якщо недоступний тип, на основі якого він оголошений.

Всі чотири рівня доступу мають лише елементи типів і конструктори. Це:

  • public;
  • private;
  • protected;
  • якщо не вказано ні один з цих трьох типів, то рівень доступу визначається за замовчуванням (default).

Перші два з них вже були розглянуті. Останній рівень (доступ за замовчуванням) згадувався в минулій лекції - він допускає звернення з того ж пакету, де оголошений і сам цей клас. З цієї причини пакети в Java є не просто набором типів, а більш структурованою одиницею, так як типи всередині одного пакету можуть більше взаємодіяти один з одним, ніж з типами з інших пакетів.

Нарешті, protected дає доступ спадкоємцям класу. Зрозуміло, що спадкоємцям може знадобитися доступ до деяких елементів батька, з якими не доводиться мати справу зовнішнім класам.

Однак описана структура не дозволяє впорядкувати модифікатори доступу так, щоб кожен наступний суворо розширював попередній. Модифікатор protected може бути зазначений для спадкоємця з іншого пакета, а доступ за замовчуванням допускає звернення з класів-ненаследніков, якщо вони знаходяться в тому ж пакеті. З цієї причини можливості protected були розширені таким чином, що він включає в себе доступ всередині пакету. Отже, модифікатори доступу упорядковуються наступним чином (від менш відкритих - до більш відкритим):

  • private
  • (None) default
  • protected
  • public

Ця послідовність буде використана далі при вивченні деталей спадкування класів.

Тепер розглянемо, які модифікатори доступу можливі для різних елементів мови.

  • Пакети доступні завжди, тому в них немає модифікаторів доступу (можна сказати, що всі вони public, тобто будь-який існуючий в системі пакет може використовуватися з будь-якої точки програми).
  • Типи (класи та інтерфейси) верхнього рівня оголошення. При їх оголошенні існує всього дві можливості: вказати модифікатор public чи не вказувати його. Якщо доступ до типу є public, то це означає, що він доступний з будь-якої точки коду. Якщо ж він не public, то рівень доступу призначається за замовчуванням: тип доступний тільки всередині того пакету, де він оголошений.
  • Масив має той же рівень доступу, що і тип, на основі якого він оголошений (природно, всі примітивні типи є повністю доступними).
  • Елементи й конструктори об'єктних типів. Мають усі чотири можливі значення рівня доступу. Всі елементи інтерфейсів є public.

Для типів оголошення верхнього рівня немає необхідності у всіх чотирьох рівнях доступу. Private-типи утворювали б закриту міні-програму, ніхто не міг би їх використовувати. Типи, доступні тільки для спадкоємців, також не були визнані корисними.

Розмежування доступу позначаються не тільки на зверненні до елементів об'єктних типів або пакетів (через складене ім'я або пряме звернення), але також при виклику конструкторів, спадкуванні, приведенні типів. Імпортувати недоступні типи забороняється.

Перевірка рівня доступу проводиться компілятором. Зверніть увагу на наступні приклади:

 public class Wheel {
   private double radius;
   public double getRadius () {
      return radius;
   }
}

Значення поля radius недоступне зовні класу, однак відкритий метод getRadius () коректно повертає його.

Розглянемо наступні два модулі компіляції:

 package first;
 // Певний клас Parent
public class Parent {
}
package first;
// Клас Child успадковується від класу Parent,
// Але має обмеження доступу за замовчуванням
class Child extends Parent {
}
public class Provider {
   public Parent getValue () {
      return new Child ();
   }
}

До методу getValue () класу Provider можна звернутися і з іншого пакета, не тільки з пакету first, оскільки метод оголошений як public. Даний метод повертає екземпляр класу Child, який недоступний з інших пакетів. Проте наступний виклик є коректним:

 package second;
import first .*;
public class Test {
  public static void main (String s [])
  {
   Provider pr = new Provider ();
   Parent p = pr.getValue ();
   System.out.println (p.getClass (). GetName ());
   // (Child) p - призведе до помилки компіляції!
  }
}

Результатом буде:

 first.Child

Тобто насправді в класі Test робота йде з примірником недоступного класу Child, що можливо, оскільки звернення до нього робиться через відкритий клас Parent. Спроба ж виконати явне приведення викличе помилку. Так, тип об'єкта "вгаданий" вірно, але доступ до закритого типу завжди заборонений.

Наступний приклад:

 public class Point {
  private int x, y;
  public boolean equals (Object o) {
    if (o instanceof Point) {
      Point p = (Point) o;
      return p.x == x & & p.y == y;
    }
    return false;
  }
}

У цьому прикладі оголошується клас Point з двома полями, що описують координати точки. Зверніть увагу, що поля повністю закриті - private. Далі спробуємо перевизначити стандартний метод equals () таким чином, щоб для аргументів, які є екземплярами класу Point, або його спадкоємців (логіка роботи оператора instanceof), у разі рівності координат поверталося справжнє значення. Зверніть увагу на рядок, де робиться порівняння координат, - для цього доводиться звертатися до private-полів іншого об'єкта!

Тим не менш, така дія коректна, оскільки private допускає звернення з будь-якої точки класу, незалежно від того, до якого саме об'єкту вона прикріпляється.

Оголошення класів

Розглянемо базові можливості оголошення класів.

Оголошення класу складається із заголовка і тіла класу.

Тема класу

Спочатку вказуються модифікатори класу. Модифікатори доступу для класу вже обговорювалися. Допустимим є public, або його відсутність - доступ за замовчуванням.

Клас може бути оголошений як final. У цьому випадку не допускається створення спадкоємців такого класу. На своїй гілці спадкування він є останнім. Клас String і класи-обгортки, наприклад, являють собою final-класи.

Після списку модифікаторів вказується ключове слово class, а потім ім'я класу - коректний Java-ідентифікатор. Таким чином, найкоротшим оголошенням класу може бути такий модуль компіляції:

 class A {}

Фігурні дужки позначають тіло класу, але про нього пізніше.

Зазначений ідентифікатор стає простим ім'ям класу. Повне складене ім'я класу будується з повного складеного імені пакету, в якому він оголошений (якщо це не безіменний пакет), і простого імені класу, розділених крапкою. Область видимості класу, де він може бути доступний за своїм простим ім’ям, - його пакет.

Далі заголовок може містити ключове слово extends, після якого має бути вказано ім'я (просте чи складене) доступного не-final класу. У цьому випадку оголошений клас успадковується від зазначеного класу. Якщо вираз extends не застосовується, то клас успадковується безпосередньо від Object. Вираз extends Object допускається і ігнорується.

 class Parent {}
   // = Class Parent extends Object {}
final class LastChild extends Parent {}
   // Class WrongChild extends LastChild {}
   // Помилка!

Спроба розширити final-клас призведе до помилки компіляції.

Якщо в оголошенні класу A зазначено вираз extends B, то клас A називають прямим спадкоємцем класу B.

Клас A вважається спадкоємцем класу B, якщо:

  • A є прямим спадкоємцем B;
  • існує клас C, який є спадкоємцем B, а A є спадкоємцем C (це правило застосовується рекурсивно).

Таким чином можна простежити ланцюжки спадкування на кілька рівнів вгору.

Якщо компілятор виявляє, що клас є своїм спадкоємцем, виникає помилка компіляції:

 // Приклад викличе помилку компіляції
class A extends B {}
class B extends C {}
class C extends A {}
// Помилка! Клас А став своїм спадкоємцем

Далі в заголовку може бути вказано ключове слово implements, за яким має слідувати перерахування через кому імен (простих або складових, повторення заборонені) доступних інтерфейсів:

 public final class String implements
                   Serializable, Comparable {}

У цьому випадку говорять, що клас реалізує перераховані інтерфейси. Як видно з прикладу, клас може реалізовувати будь-яку кількість інтерфейсів. Якщо вираз implements відсутній, то клас дійсно не реалізує ніяких інтерфейсів, тут значень за замовчуванням немає.

Далі слідує пара фігурних дужок, які можуть бути порожніми або містити опис тіла класу.

Тіло класу

Тіло класу може містити оголошення елементів (members) класу:

  • полів;
  • внутрішніх типів (класів та інтерфейсів);

і інших допустимих конструкцій:

  • конструкторів;
  • ініціалізаторов
  • статичних ініціалізаторов.

Елементи класу мають імена і передаються у спадок, не-елементи - ні. Для елементів прості імена вказуються при оголошенні, складові формуються з імені класу, або імені змінної об'єктного типу, і простого імені елемента. Областю видимості елементів є все оголошення тіла класу. Допускається застосування будь-якого з усіх чотирьох модифікаторів доступу. Нагадуємо, що угоди іменування класів та їх елементів обговорювалися в минулій лекції.

Не-елементи не володіють іменами, а тому не можуть бути викликані явно. Їх викликає сама віртуальна машина. Наприклад, конструктор викликається при створенні об'єкту. З тієї ж причини не-елементи не володіють модифікаторами доступу.

Елементами класу є елементи, описані в оголошенні тіла класу і передані у спадок від класу-батька (крім Object - єдиного класу, який не має одного з батьків) і всіх реалізованих інтерфейсів за умови достатнього рівня доступу. Таким чином, якщо клас містить елементи з доступом за замовчуванням, то його спадкоємці з різних пакетів будуть володіти різним набором елементів. Класи з того ж пакету можуть користуватися повним набором елементів, а з інших пакетів - тільки protected і public. private-елементи у спадок не передаються.

Поля і методи можуть мати однакові імена, оскільки звернення до полів завжди записується без дужок, а до методів - завжди з дужками.

Розглянемо всі ці конструкції більш докладно.

 Оголошення полів

Оголошення полів починається з перерахування модифікаторів. Можливе застосування будь-якого з трьох модифікаторів доступу, або ніякого зовсім, що означає рівень доступу.

Поле може бути оголошене як final, це означає, що воно ініціалізується один раз і більше не буде змінювати свого значення. Найпростіший спосіб роботи з final-змінними - ініціалізація при оголошенні:

 final double PI = 3.1415;

Також допускається ініціалізація final-полів в кінці кожного конструктора класу.

Не обов'язково використовувати для ініціалізації константи компіляції, можливе звернення до різних функцій, наприклад:

 final long creationTime = System.currentTimeMillis ();

Дане поле буде зберігати час створення об'єкта. Існує ще два спеціальних модифікатора - transient і volatile. Вони будуть розглянуті у відповідних лекціях.

Після списку модифікаторів вказується тип поля. Потім іде перерахування одного або кількох імен полів з можливими ініціалізатор:

 int a;
int b = 3, c = b +5, d;
Point p, p1 = null, p2 = new Point ();

Повторювані імена полів заборонені. Зазначений ідентифікатор при оголошенні стає простим іменем поля. Складене ім'я формується з імені класу або імені змінної об'єктного типу, і простого імені поля. Областю видимості поля є всі оголошення тіла класу.

Забороняється використовувати поле в ініціалізації інших полів до його оголошення.

 int y = x;
int x = 3;

Однак в іншому полі можна оголошувати і нижче їх використання:

 class Point {
   int getX () {return x;}
   int y = getX ();
   int x = 3;
}
public static void main (String s []) {
      Point p = new Point ();
      System.out.println (p.x + "," + p.y);
}

Результатом буде:

 3, 0

Даний приклад коректний, але для розуміння його результату необхідно згадати, що всі поля класу мають значення за замовчуванням:

  • для числових полів примітивних типів - 0;
  • для булевского типу - false;
  • для вказівних - null.

Таким чином, при ініціалізації змінної y був використаний результат методу getX(), який повернув значення за замовчуванням змінної x, тобто 0. Потім змінна x отримала значення 3.

Оголошення методів

Оголошення методу складається із заголовка і тіла методу. Заголовок складається з:

  • модифікаторів (доступу в тому числі);
  • типу значення або ключового слова void;
  • імені методу;
  • списку аргументів у круглих дужках (аргументів може не бути);
  • спеціального throws-вирази.

Тема починається з перерахування модифікаторів. Для методів доступний будь-який з трьох можливих модифікаторів доступу. Також допускається використання доступу.

Крім того, існує модифікатор final, який говорить про те, що такий метод не можна перевизначати в спадкоємців. Можна вважати, що всі методи final-класу, а також всі private-методи будь-якого класу, є final.

Також підтримується модифікатор native. Метод, оголошений з таким модифікатором, не має реалізації на Java. Він повинен бути написаний на іншій мові (C / C + +, Fortran і т.д.) і доданий до системи у вигляді завантажується динамічної бібліотеки (наприклад, DLL для Windows). Існує спеціальна специфікація JNI (Java Native Interface), що описує правила створення і використання native-методів.

Така можливість для Java необхідна, оскільки багато компаній мають великі програмні бібліотеки, написані на більш старих мовами. Їх було б дуже трудомістко й неефективно переписувати на Java, тому необхідна можливість підключати їх в такому вигляді, в якому вони є. Безумовно, при цьому Java-додатки втрачають цілий ряд своїх переваг, таких, як переносимість, безпека та інші. Тому застосовувати JNI слід тільки в разі крайньої необхідності.

Ця специфікація накладає вимоги на імена процедур в зовнішніх бібліотеках (вона складає їх з імені пакета, класу і самого native-методу), а оскільки бібліотеки міняти, як правило, дуже незручно, часто пишуть спеціальні бібліотеки-"обгортки", до яких звертаються Java -класи через JNI, а вони самі звертаються до цільових модулів.

Нарешті, існує ще один спеціальний модифікатор synchronized, який буде розглянуто в лекції, яка описує потоки виконання.

Після перерахування модифікаторів вказується ім'я (просте чи складене) типу значення, оскільки це може бути як примітивний, так і об'єктний тип. Якщо метод не повертає ніякого значення, вказується ключове слово void.

Потім визначається ім'я методу. Зазначений ідентифікатор при оголошенні стає простим ім'ям методу. Складене ім'я формується з імені класу або імені змінної об'єктного типу і простого імені методу. Областю видимості методу є всі оголошення тіла класу.

Аргументи методу перераховуються через кому. Для кожного вказується спочатку тип, потім ім'я параметра. На відміну від оголошення змінної тут забороняється вказувати два імені для одного типу:

 / / Void calc (double x, y); - помилка!
void calc (double x, double y);

Якщо аргументи відсутні, вказуються порожні круглі дужки. Однойменні параметри заборонені. Створення локальних змінних в методі, з іменами, що збігаються з іменами параметрів, заборонено. Для кожного аргументу можна ввести ключове слово final перед зазначенням його типу. У цьому випадку такий параметр не може змінювати свого значення в тілі методу (тобто брати участь в операції привласнення в якості лівого операнда).

 public void process (int x, final double y) {
   x = x * x + Math.sqrt (x);
   // Y = Math.sin (x); - так писати не можна,
   // Тому y - final!
}

Про те, як відбувається зміна значень аргументів методу, розказано в кінці цієї лекції.

Важливим поняттям є сигнатура (signature) методу. Сигнатура визначається ім'ям методу і його аргументами (кількістю, типом, порядком слідування). Якщо для полів забороняється збіг імен, то для методів у класі заборонено створення двох методів з однаковими сигнатурами.

Наприклад,

 class Point {
   void get () {}
   void get (int x) {}
   void get (int x, double y) {}
   void get (double x, int y) {}
}

Такий клас оголошений коректно. Наступні пари методів в одному класі один з одним несумісні:

 void get () {}
int get () {}
void get (int x) {}
void get (int y) {}
public int get () {}
private int get () {}

У першому випадку методи відрізняються типом значення, що повертається, яке, проте, не входить у визначення сигнатури. Стало бути, це два методи з однаковими сигнатурами і вони не можуть одночасно з'явитися в оголошенні тіла класу. Можна скласти приклад, який створив би нерозв'язну проблему для компілятора, якби був скажімо:

 // Приклад викличе помилку компіляції
class Test {
   int get () {
      return 5;
   }
   Point get () {
      return new Point (3,5);
   }
   void print (int x) {
      System.out.println ("it's int!" + X);
   }
   void print (Point p) {
      System.out.println ("it's Point!" + P.x +
                         "," + P.y);
   }
   public static void main (String s []) {
      Test t = new Test ();
      t.print (t.get ()); // Двозначність!
   }
}

У класі визначена заборонена пара методів get () з однаковими сигнатурами і різними значеннями, що повертаються. Звернемося до виділеному рядку в методі main, де виникає конфліктна ситуація, з якою компілятор не може впоратися. Визначено два методи print () (у них різні аргументи, а значить, і сигнатури, тобто це допустимі методи), і щоб розібратися, який з них буде викликаний, потрібно знати точний тип значення методу get (), що неможливо.

На основі цього прикладу можна зрозуміти, як складено поняття сигнатури. Дійсно, при виклику вказується ім'я методу і перераховуються його аргументи, причому компілятор завжди може визначити їх тип. Як раз ці поняття і складають сигнатуру, і вимога її унікальності дозволяє компілятору завжди однозначно визначити, який метод буде викликаний.

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

Аналогічно, третя пара різниться лише модифікаторами доступу, що також неприпустимо.

Нарешті, завершує заголовок методу throws-вираз. Воно застосовується для коректної роботи з помилками в Java і буде детально розглянуто у відповідній лекції.

Приклад оголошення методу:

 public final java.awt.Point
   createPositivePoint (int x, int y)
   throws IllegalArgumentException
{
   return (x> 0 && y> 0)?
           new Point (x, y): null;
}

Далі, після заголовка методу слідує тіло методу. Воно може бути порожнім і тоді записується одним символом "крапка з комою". Native-методи завжди мають тільки пусте тіло, бо справжня реалізація написана на іншій мові.

Звичайні ж методи мають непорожнє тіло, яке описується в фігурних дужках, що показано в численних прикладах в цій та інших лекціях. Якщо поточна реалізація методу не виконує жодних дій, тіло все одно має описуватися парою порожніх фігурних дужок:

 public void empty () {}

Якщо в заголовку методу зазначений тип значення, що повертається, а не void, то в тілі методу обов'язково повинно зустрічатися return-вираз. При цьому компілятор проводить аналіз структури методу, щоб гарантувати, що при будь-яких операторах розгалуження повертається значення буде згенеровано. Наприклад, наступний приклад є некоректним:

 // Приклад викличе помилку компіляції
public int get () {
   if (condition) {
      return 5;
   }
}

Видно, що хоча тіло методу містить return-вираз, проте не за будь-якого розвитку подій повертається значення буде згенеровано. А ось такий приклад є вірним:

 public int get () {
   if (condition) {
      return 5;
   } Else {
      return 3;
   }
}

Звичайно, значення, вказане після слова return, має бути сумісним з типом з оголошеним повертається значенням (це поняття детально розглядається в лекції 7).

У методі без значення, що повертається (зазначено void) також можна використовувати вираз return без будь-яких аргументів. Його можна вказати в будь-якому місці методу і в цій точці виконання методу буде завершено:

 public void calculate (int x, int y) {
   if (x <= 0 | | y <= 0) {
      return; // некоректні вхідні
                // Значення, вихід з методу
   }
   ... // Основні обчислення
}

Виразів return (з параметром або без для методів з / без значення, що повертається) у тілі одного методу може бути скільки завгодно. Однак слід пам'ятати, що безліч точок виходу в один метод може помітно ускладнити розуміння логіки його роботи.

Оголошення конструкторів

Формат оголошення конструкторів схожий на спрощений формат оголошення методів. Також виділяють заголовок і тіло конструктора. Заголовок складається, по-перше, з модифікаторів доступу (ніякі інші модифікатори неприпустимі). По-друге, вказується ім'я класу, яке можна розцінювати двояко. Можна вважати, що ім'я конструктора збігається з ім'ям класу. А можна розглядати конструктор як безіменний, а ім'я класу - як тип значення, адже конструктор може породити тільки об'єкт класу, в якому він оголошений. Це виключно справа смаку, тому що на форматі оголошення ніяк не позначається:

pu blic class Human {
   private int age;
   protected Human (int a) {
      age = a;
   }
   public Human (String name, Human mother,
                Human father) {
      age = 0;
   }
}

Як видно з прикладів, далі йде перелік вхідних аргументів за тими ж правилами, що і для методів. Завершує заголовок конструктора throws-вираз (у прикладі не використано, див. лекцію 10 "Винятки"). Воно має особливу важливість для конструкторів, оскільки згенерувати помилку - це для конструктора єдиний спосіб не створювати об'єкт. Якщо конструктор виконався без помилок, то об'єкт гарантовано створюється.

Тіло конструктора порожнім бути не може і тому завжди описується в фігурних дужках (для найпростіших реалізацій дужки можуть бути порожніми).

У відсутність імені (або через те, що у всіх конструкторів однакове ім'я, що збігається з ім'ям класу) сигнатура конструктора визначається тільки набором вхідних параметрів за тими ж правилами, що і для методів. Аналогічно, в одному класі допускається будь-яку кількість конструкторів, якщо у них різні сигнатури.

Тіло конструктора може містити будь-яку кількість return-виразів без аргументів. Якщо процес виконання дійде до такого виразу, то на цьому місці виконання конструктора буде завершено.

Однак логіка роботи конструкторів має і деякі важливі особливості. Оскільки при їх виклику здійснюється створення і ініціалізація об'єкта, стає зрозуміло, що такий процес не може відбуватися без звернення до конструкторів всіх батьківських класів. Тому вводиться обов'язкове правило - першим рядком в конструкторі має бути звернення до батьківського класу, яке записується за допомогою ключового слова super.

 public class Parent {
   private int x, y;
   public Parent () {
      x = y = 0;
   }
   public Parent (int newx, int newy) {
      x = newx;
      y = newy;
   }
}
public class Child extends Parent {
   public Child () {
      super ();
   }
   public Child (int newx, int newy) {
      super (newx, newy);
   }
}

Як видно, звернення до батьківського конструктору записується за допомогою super, за яким йде перерахування аргументів. Цей набір визначає, який з батьківських конструкторів буде використаний. У наведеному прикладі в кожному класі є по два конструктора і кожен конструктор в спадкоємця звертається до аналогічного в батьку (це досить поширений, але, звичайно, не обов'язковий спосіб).

Простежимо подумки весь алгоритм створення об'єкта. Він починається при виконанні вираження з ключовим словом new, за яким слідує ім'я класу, від якого буде породжуватися об'єкт, і набір аргументів для його конструктора. З цього набору визначається, який саме конструктор буде використаний, і відбувається його виклик. Перший рядок його тіла містить виклик батьківського конструктора. У свою чергу, перший рядок тіла конструктора батька буде містити виклик до його батьків, і так далі. Сходження по дереву успадкування закінчується, очевидно, на класі Object, у якого є єдиний конструктор без параметрів. Його тіло порожнє (записується парою порожніх фігурних дужок), проте можна вважати, що саме в цей момент JVM породжує об'єкт і далі починається процес його ініціалізації. Виконання починає зворотний шлях вниз по дереву наслідування. У самого верхнього батька, прямого спадкоємця від Object, відбувається продовження виконання конструктора із другого рядка. Коли він буде повністю виконаний, необхідно перейти до наступного батьківського елементу, на один рівень наслідування вниз, і завершити виконання його конструктора, і так далі. Нарешті, можна буде повернутися до конструктора вихідного класу, який був викликаний за допомогою new, і також продовжити його виконання з другого рядка. По його завершенні об'єкт вважається повністю створеним, виконання вираження new буде закінчено, а в якості результату буде повернена посилання на породжений об'єкт.

Проілюструємо цей алгоритм наступним прикладом:

 public class GraphicElement {
   private int x, y; //  положення на екрані
   public GraphicElement (int nx, int ny) {
      super (); // звернення до конструктора
                 // Батька Object
      System.out.println ("GraphicElement");
      x = nx;
      Y = ny;
      }
}
public class Square extends GraphicElement {
   private int side;
   public Square (int x, int y, int nside) {
      super (x, y);
      System.out.println ("Square");
         side = nside;
   }
}
public class SmallColorSquare extends Square {
   private Color color;
   public SmallColorSquare (int x, int y,
                           Color c) {
      super (x, y, 5);
      System.out.println ("SmallColorSquare");
      color = c;
   }
}

Після виконання вираження створення об'єкта на екрані з'явиться наступне:

 GraphicElement
Square
SmallColorSquare

Вираз super може стояти тільки на першому рядку конструктора. Часто можна побачити конструктори взагалі без такого виразу. У цьому випадку компілятор першим рядком за замовчуванням додає виклик батьківського конструктора без параметрів (super ()). Якщо у батьківського класу такого конструктора немає, вираз super обов'язково має бути записано явно (і саме на першому рядку), оскільки необхідна передача вхідних параметрів.

Нагадаємо, що, по-перше, конструктори не мають імені і їх не можна викликати явно, тільки через вираження створення об'єкта. Крім того, конструктори не передаються у спадок. Тобто, якщо в батьківському класі оголошено п'ять різних корисних конструкторів і потрібно, щоб клас-спадкоємець мав аналогічний набір, необхідно всіх їх описати заново.

Клас обов'язково повинен мати конструктор, інакше неможливо породжувати об'єкти ні від нього, ні від його спадкоємців. Тому якщо в класі не оголошено ні один конструктор, компілятор додає один за замовчуванням. Це public-конструктор без параметрів і з тілом, описаним парою порожніх фігурних дужок. З цього випливає, що таке можливо тільки для класів, у батьків яких оголошено конструктор без параметрів, інакше виникне помилка компіляції. Зверніть увагу, що якщо потім у такий клас додається конструктор (не важливо, з параметрами або без), то конструктор за замовчуванням більше не вставляється:

 /*
   * Цей клас має один конструктор.
   */
public class One {
   // Буде створений конструктор за умовчанням
   // Батьківський клас Object має
   // Конструктор без параметрів.
}
/*
   * Цей клас має один конструктор.
   */
public class Two {
   // Єдиний конструктор класу Two.
   // Вираз new Two () помилкове!
   public Two (int x) {
   }
}

/*
   * Цей клас має два конструктора.
   */
public class Three extends Two {
   public Three () {
      super (1); / / вираз super потрібно
   }
   public Three (int x) {
      super (x); / / вираз super потрібно
   }
}

Якщо клас має більше одного конструктора, допускається в першому рядку деяких з них вказувати не super, а this - вираз, що викликає інший конструктор цього ж класу.

Розглянемо наступний приклад:

 public class Vector {
   private int vx, vy;
   protected double length;
   public Vector (int x, int y) {
      super ();
      vx = x;
      vy = y;
      length = Math.sqrt (vx * vx + vy * vy);
   }
   public Vector (int x1, int y1,
                 int x2, int y2) {
      super ();
      vx = x2-x1;
      vy = y2-y1;
      length = Math.sqrt (vx * vx + vy * vy);
   }
}

Видно, що обидва конструктора здійснюють практично ідентичні дії, тому можна застосувати більш компактний вид запису:

  public class Vector {
   private int vx, vy;
   protected double length; 
   public Vector (int x, int y) {
      super ();
      vx = x;
      vy = y;
      length = Math.sqrt (vx * vx + vy * vy);
   }

   public Vector (int x1, int y1,
                 int x2, int y2) {
      this (x2-x1, y2-y1);
   }
}

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

Зрозуміло, таке звернення до конструкторів свого класу не повинно призводити до зациклення, інакше буде видана помилка компіляції. Ланцюжок this повинен в підсумку спричинить super, який повинен бути присутнім (явно чи неявно) хоча б в одному з конструкторів. Після того, як відпрацюють конструктори всіх батьківських класів, буде продовжено виконання кожного конструктора, залученого в процес створення об'єкта.

 public class Test {
   public Test () {
      System.out.println ("Test ()");
   }
   public Test (int x) {
      this ();
      System.out.println ("Test (int x)");
   }
}

Після виконання вираження new Test (0) на консолі з'явиться:

 Test ()
Test (int x)

На закінчення розглянемо застосування модифікаторів доступу для конструкторів. Може викликати подив можливість оголошувати конструктори як private. Адже вони потрібні для генерації об'єктів, а до таких конструкторам ні в кого не буде доступу. Проте у ряді випадків модифікатор private може бути корисний. Наприклад:

  • private-конструктор може містити ініціалізували дії, а інші конструктори будуть використовувати його за допомогою this, причому пряме звернення до цього конструктору з якихось причин небажано;
  • заборона на створення об'єктів цього класу, наприклад, неможливо створити екземпляр класу Math;
  • реалізація спеціального шаблона проектування з ООП Singleton, для роботи якого потрібна контролювати створення об'єктів, що неможливо у разі наявності не-private конструкторів.

Ініціалізатор

Нарешті, останньою допустимою конструкцією в тілі класу є оголошення ініціалізаторов. Записуються об'єктні ініціалізатор дуже просто - всередині фігурних дужок.

 public class Test {
  private int x, y, z;
   // Ініціалізатор об'єкта
   {
      x = 3;
      if (x> 0)
         y = 4;
      z = Math.max (x, y);
   }
}

Ініціалізатори не мають імен, виконуються при створенні об'єктів, не можуть бути викликані явно, не передаються у спадщину (хоча, звичайно, ініціалізатор в батьківському класі продовжують виконуватися при створенні об'єкту класу-спадкоємця).

Було зазначено вже три види ініціюючого коду в класах - конструктори, ініціалізатори змінних, а тепер додалися об'єктні ініціалізатори. Необхідно розібратися, в якій послідовності що виконується, в тому числі при спадкуванні. При створенні екземпляра класу викликаний конструктор виконується таким чином:

  • якщо першим рядком йде звернення до конструктора батьківського класу (явне або доданий компілятором за замовчуванням), то цей конструктор виконується;
  • у разі успішного виконання викликаються всі ініціалізатори полів і об'єктів в тому порядку, в якому вони оголошені в тілі класу;
  • якщо першим рядком йде звернення до іншого конструктору цього ж класу, то він викликається. Повторне виконання ініціалізаторов не проводиться.

Другий пункт має ряд важливих наслідків. По-перше, з нього випливає, що в ініціалізатор не може використовувати змінні класу, якщо їх оголошення записано пізніше.

По-друге, тепер можна сформулювати найбільш гнучкий підхід до ініціалізації final-полів. Головна вимога - щоб такі поля були проініціалізувати рівно один раз. Це можна забезпечити в наступних випадках:

  • якщо ініціалізувати поле при оголошенні;
  • якщо ініціалізувати поле тільки один раз на ініціалізаторі об'єкта (він повинен бути записаний після оголошення поля);
  • якщо ініціалізувати поле тільки один раз в кожному конструкторі, в першому рядку якого стоїть явне або неявне звернення до конструктора батька. Конструктор, в першому рядку якого стоїть this, не може і не повинен ініціалізувати final-поле, оскільки ланцюжок this-дзвінків призведе до конструктора з super, в якому ця ініціалізація обов'язково присутня.

Для ілюстрації порядку виконання ініціюючих конструкцій розглянемо наступний приклад:

 public class Test {
   {
      System.out.println ("initializer");
   }
   int x, y = getY ();
   final int z;
   {
      System.out.println ("initializer2");
   }
   private int getY () {
      System.out.println ("getY ()" + z);
      return z;
   }
   public Test () {
      System.out.println ("Test ()");
      z = 3;
   }
   public Test (int x) {
      this ();
      System.out.println ("Test (int)");
      // Z = 4; - не можна! final-поле вже
      // Було ініціалізовано!
   }
}

Після виконання вираження new Test () на консолі з'явиться:

 initializer
getY () 0
initializer2
Test () 

Зверніть увагу, що для ініціалізації поля y викликається метод getY (), який повертає значення final-поля z, яке ще не було ініціалізований. Тому в підсумку полі y отримає значення за замовчуванням 0, а потім поле z отримає постійне значення 3, яке ніколи вже не зміниться.

Після виконання вираження new Test (3) на консолі з'явиться:

in itializer
getY () 0
initializer2
Test ()
Test (int) 

Додаткові властивості класів

Розглянемо в цьому розділі деякі особливості роботи з класами в Java. Обговорення даного питання буде продовжено у спеціальній лекції, присвяченій об'єктної моделі в Java.

 Метод main

Отже, віртуальна машина реалізується додатком операційної системи і запускається за звичайними правилами. Програма, написана на Java, є набором класів. Зрозуміло, що потрібно якась вхідні точка, з якої має починатися виконання програми.

Такий вхідний точкою, за аналогією з мовами C / C + +, є метод main (). Приклад його оголошення:

 public static void main (String [] args) {}

Модифікатор static в цій лекції не розглядалося і буде вивчений пізніше. Він дозволяє викликати метод main (), не створюючи об'єктів. Метод не повертає ніякого значення, хоча в C є можливість вказати код повернення з програми. У Java для цієї мети існує метод System.exit (), який закриває віртуальну машину і має аргумент типу int.

Аргументом методу main () є масив рядків. Він заповнюється додатковими параметрами, які були вказані при виклику методу.

 package test.first;
public class Test {
   public static void main (String [] args) {
      for (int i = 0; i <args.length; i + +) {
         System.out.print (args [i] + "");
      }
      System.out.println ();
   }
} 

Для виклику програми віртуальній машині передається як параметр ім'я класу, у якого оголошено метод main (). Оскільки це ім'я класу, а не ім'я файлу, то не повинно вказуватися ніякого розширення (. Class або. Java) і розташування класу записується через точку (роздільник імен пакетів), а не за допомогою файлового роздільника. Компілятору ж, навпаки, передається ім'я і шлях до файлу.

Якщо наведений вище модуль компіляції збережений у файлі Test.java, який лежить в каталозі test \ first, то виклик компілятора записується таким чином:

 javac test \ first \ Test.java

А виклик віртуальної машини:

java test.first.Test 

Щоб проілюструвати роботу з параметрами, змінимо рядок запуску програми:

java test.first.Test Hello, World! 

Результатом роботи програми буде:

H ello, World!

Параметри методів

Для кращого розуміння роботи з параметрами методів в Java необхідно розглянути кілька питань.

Як передаються аргументи на методи - за значенням або за посиланням? З точки зору програми питання формулюється, наприклад, наступним чином. Нехай є змінна і вона як аргумент передається в деякий метод. Чи можуть відбутися будь-які зміни з цієї змінної після завершення роботи методу?

 int x = 3;
process (x);
print (x);

Припустимо, використовуваний метод оголошений наступним чином:

 public void process (int x) {
   x = 5;
}

Яке значення з'явиться на консолі після виконання прикладу? Щоб відповісти на це питання, необхідно згадати, як змінні різних типів зберігають свої значення в Java.

Нагадаємо, що примітивні змінні є істинними сховищами своїх значень і зміна значення однієї змінної ніколи не позначиться на значенні іншого. Параметр методу process (), хоч і має таке ж ім'я x, насправді є повноцінним сховищем цілечисельної величини. А тому присвоєння йому значення 5 не позначиться на зовнішніх змінних. Тобто результатом прикладу буде 3 та аргументи примітивного типу передаються в методи за значенням. Єдиний спосіб змінити таку змінну в результаті роботи методу - повертати потрібні величини з методу і використовувати їх при присвоєнні:

  public int doubler (int x) {
   return x + x;
}

public void test () {
   int x = 3;
   x = doubler (x);
} 

Перейдемо до вказівних типів.

 public void process (Point p) {
   p.x = 3;
}

public void test () {
   Point p = new Point (1,2);
   process (p);
   print (p.x);
}

Зміна-вказівник зберігає посилання на об'єкт, що знаходиться в пам'яті віртуальної машини. Тому аргумент методу process () буде мати в якості значення ту ж саму посилання і, отже, посилатися на той же самий об'єкт. Зміни стану об'єкта, здійснені за допомогою одного посилання, завжди видно при зверненні до цього об'єкту за допомогою іншої. Тому результатом прикладу буде значення 3. Об'єктні значення передаються в Java по посиланню. Однак якщо змінювати не стан об'єкта, а саме посилання, то результат буде іншим:

 public void process (Point p) {
   p = new Point (4,5);
}

public void test () {
   Point p = new Point (1,2);
   process (p);
   print (p.x);
}

У цьому прикладі аргумент методу process () після присвоєння починає посилатися на інший об'єкт, ніж вихідна змінна p, а значить, результатом прикладу стане значення 1. Можна сказати, що вказівні величини передаються за значенням, але значенням є саме посилання на об'єкт.

Тепер можна уточнити, що означає можливість оголошувати параметри методів і конструкторів як final. Оскільки зміни значень параметрів (але не об'єктів, на які вони посилаються) ніяк не позначаються на змінних поза методу, модифікатор final говорить лише про те, що значення цього параметра не буде змінюватися протягом роботи методу. Зрозуміло, для аргументу final Point p вираз px = 5 є допустимим (забороняється p = new Point (5, 5)).

Перевантажені методи

Перевантаженими (overloaded) методами називаються методи одного класу з однаковими іменами. Сигнатури у них повинні бути різними і різниця може бути тільки в наборі аргументів.

Якщо в класі параметри перевантажених методів помітно різняться: наприклад, в одного методу один параметр, в іншого - два, то для Java це абсолютно незалежні методи і збіг їхніх імен може служити тільки для підвищення наочності роботи класу. Кожен виклик, в залежності від кількості параметрів, однозначно адресується того чи іншого методу.

Проте якщо кількість параметрів однакова, а типи їх різняться незначно, при виклику може скластися двоїста ситуація, коли кілька перевантажених методів однаково добре підходять для використання. Наприклад, якщо оголошені типи Parent і Child, де Child розширює Parent, то для наступних двох методів:

 void process (Parent p, Child c) {}
void process (Child c, Parent p) {}

можна сказати, що вони допустимі, їх сигнатури розрізняються. Однак при виклику

 process (new Child (), new Child ());

виявляється, що обидва методи однаково придатні для використання. Інший приклад, методи:

 process (Object o) {}
process (String s) {}

і приклади викликів:

 process (new Object ());
process (new Point (4,5));
process ("abc");

Очевидно, що для перших двох викликів підходить тільки перший метод, і саме він буде викликаний. Для останнього ж виклику підходять обидва перевантажених методи, однак клас String є більш "специфічним", або вузьким, ніж клас Object. Дійсно, значення типу String можна передавати в якості аргументів типу Object, зворотне ж невірно. Компілятор спробує відшукати найбільш специфічний метод, що підходить для зазначених параметрів, і викличе саме його. Тому при третьому виклику буде використаний другий метод.

Однак для попереднього прикладу такий підхід не дає однозначної відповіді. Обидва методи однаково специфічні для зазначеного виклику, тому виникне помилка компіляції. Необхідно, використовуючи явне приведення, вказати компілятору, який метод слід застосувати:

 process ((Parent) (new Child ()), new Child ());
// Або
process (new Child (), (Parent) (new Child ()));

Це вірно й у випадку використання значення null:

 process ((Parent) null, null);
// Або
process (null, (Parent) null);

Висновок

У цій лекції почався розгляд ключовою конструкції мови Java - оголошення класу.

Перша тема присвячена засобам розмежування доступу. Головне питання - для чого цей механізм вводиться в практично кожному сучасному мовою високого рівня. Необхідно розуміти, що він призначений не для забезпечення "безпеки" або "захисту" об'єкта від якихось неправильних дій. Найважливіше завдання - розділити зовнішній інтерфейс класу і деталі його реалізації з тим, щоб надалі скористатися такими перевагами ООП, як інкапсуляція і модульність.

Потім були розглянуті всі чотири модифікатора доступу, а також можливість їх застосування для різних елементів мови. Перевірка рівня доступу виконується вже під час компіляції і забороняє лише явне використання типів. Наприклад, з ними все ж можна працювати через їх більш відкритих спадкоємців.

Оголошення класу складається із заголовка і тіла класу. Формат заголовка був детально описаний. Для вивчення тіла класу необхідно згадати поняття елементів (members) класу. Ними можуть бути поля, методи та внутрішні типи. Для методів важливим поняттям є сигнатура.

Крім того, в тілі класу оголошуються конструктори і ініціалізатор. Оскільки вони не є елементами, до них не можна звернутися явно, вони викликаються самої віртуальною машиною. Також конструктори і ініціалізатор не передаються у спадок.

Додатково був розглянутий метод main, який викликається при старті віртуальної машини. Далі описуються тонкощі, що виникають при передачі параметрів, і пов'язані з цим питання про перевантажені методи.

Класи Java ми продовжимо розглядати в наступних лекціях.

загрузка...
Теми розділу
Сторінки, близькі за змістом