Вітання! У цій лекції поговоримо про те, чим абстрактні класи відрізняються від інтерфейсів, та розглянемо приклади поширених абстрактних класів. Різниця між абстрактними класами та інтерфейсами - 1Ми присвятимо відмінностям абстрактного класу та інтерфейсу окрему лекцію, оскільки ця тема є дуже важливою. Про різницю між цими поняттями тебе спитають на 90% майбутніх співбесід. Тому обов'язково розберися з прочитаним, а якщо щось зрозумієш не до кінця, пошукай додаткові джерела. Отже, ми знаємо, що таке абстрактний клас і що таке інтерфейс. Тепер пройдемося за їхніми відмінностями.
  1. Інтерфейс описує лише поведінку. Він не має стану. А в абстрактного класу стан є: він описує і те, й інше.

    Візьмемо для прикладу абстрактний клас Bird та інтерфейс Flyable:

    
    public abstract class Bird {
       private String species;
       private int age;
    
       public abstract void fly();
    
       public String getSpecies() {
           return species;
       }
    
       public void setSpecies(String species) {
           this.species = species;
       }
    
       public int getAge() {
           return age;
       }
    
       public void setAge(int age) {
           this.age = age;
       }
    }
    

    Давай створимо клас птиці Mockingjay (сойка-пересмішниця) і успадкуємо його від Bird:

    
    public class Mockingjay extends Bird {
    
       @Override
       public void fly() {
           System.out.println("Лети, пташка!");
       }
    
       public static void main(String[] args) {
    
           Mockingjay someBird = new Mockingjay();
           someBird.setAge(19);
           System.out.println(someBird.getAge());
       }
    }
    

    Як бачиш, ми легко отримуємо доступ до стану абстрактного класу – до його змінних species (вид) та age (вік).

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

    
    public interface Flyable {
    
       String species = new String();
       int age = 10;
    
       public void fly();
    }
    
    public interface Flyable {
    
       private String species = new String(); // помилка
       private int age = 10; // теж помилка
    
       public void fly();
    }
    

    У нас навіть не вдасться створити всередині інтерфейсу private-змінні. Чому? Тому що private-модифікатор створили, щоб приховувати реалізацію від користувача. А всередині інтерфейсу реалізації немає: там і приховувати нема чого.

    Інтерфейс лише описує поведінку. Відповідно, ми не зможемо реалізувати всередині інтерфейсу гетери та сеттери. Така природа інтерфейсу: він необхідний для роботи з поведінкою, а не зі станом.

    У Java 8 з'явилися дефолтні методи інтерфейсів, які мають реалізацію. Про них ти вже знаєш, тож повторюватися не будемо.

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

    Повернемося до нашого прикладу з птахами.

    Наш абстрактний клас Bird потрібний, щоб на його основі створювати птахів. Тільки птахів і нікого більше! Звісно, вони будуть різними.

    Різниця між абстрактними класами та інтерфейсами - 2

    З інтерфейсом Flyable все по-іншому. Він лише описує поведінку, що відповідає його назві, – «літаючий». Під визначення «літаючий», «здатний літати» потрапляє багато об'єктів, які не пов'язані між собою.

    Різниця між абстрактними класами та інтерфейсами - 3

    Ці 4 сутності між собою ніяк не пов'язані. Що вже там казати, не всі з них навіть одухотворені. Тим не менш, усі вони Flyable – здатні літати.

    Ми не змогли б описати їх за допомогою абстрактного класу. Вони не мають спільного стану, однакових полів. Щоб дати характеристику літаку, нам, напевно, знадобляться поля «модель», «рік випуску» та «максимальна кількість пасажирів». Для Карлсона – поля для всіх солодощів, які він сьогодні з'їв, і список ігор, у які він гратиме з Малим. Для комара... е-е-е... навіть не знаємо... Може, рівень докучливості? :)

    Головне, що за допомогою абстрактного класу описати їх ми не можемо. Вони надто різні. Але є спільна поведінка: вони можуть літати. Інтерфейс ідеально підійде для опису всього на світі, що вміє літати, плавати, стрибати або має якусь іншу поведінку.

  3. Класи можуть реалізовувати скільки завгодно інтерфейсів, але успадковуватися можна лише від одного класу.

    Про це ми вже говорили неодноразово. Множинного успадкування в Java немає, а множинна реалізація є. Частково цей пункт випливає з попереднього: інтерфейс пов'язує між собою безліч різних класів, які часто не мають нічого спільного, а абстрактний клас створюється для групи дуже близьких один до одного класів. Тому логічно, що успадковуватися можна лише від одного такого класу. Абстрактний клас описує відносини «is a».

Стандартні інтерфейси InputStream & OutputStream

Ми вже проходили різні класи, які відповідають за потокове введення та виведення. Давай розглянемо InputStream і OutputStream. Взагалі, це не інтерфейси, а справжнісінькі абстрактні класи. Тепер ти знаєш, що це таке, тому працювати з ними буде набагато простіше :) InputStream – це абстрактний клас, який відповідає за байтове введення. У Java є серія класів, що успадковують InputStream. Кожен із них налаштований таким чином, щоб отримувати дані з різних джерел. Оскільки InputStream є батьківським класом, він надає кілька методів для зручної роботи з потоками даних. Ці методи є в кожного нащадка InputStream:
  • int available() повертає число байтів, доступних для читання;
  • close() закриває джерело введення;
  • int read() повертає цілочисельне представлення наступного доступного байта в потоці. Якщо досягнуто кінця потоку, буде повернуто число -1;
  • int read(byte[] buffer) намагається зчитати байти в буфер, повертаючи кількість прочитаних байтів. Коли досягає кінця файлу, повертає значення -1;
  • int read(byte[] buffer, int byteOffset, int byteCount) зчитує частину блоку байтів. Використовується, коли є ймовірність, що блок даних був заповнений не повністю. Коли досягає кінця файлу, повертає -1;
  • long skip(long byteCount) пропускає byteCount байтів введення, повертаючи кількість пропущених байтів.
Раджу вивчити повний перелік методів. Класів-спадкоємців насправді більше десятка. Наприклад, наведемо кілька:
  1. FileInputStream: найпоширеніший вид InputStream. Використовується для читання інформації з файлу;
  2. StringBufferInputStream: ще один корисний вид InputStream. Він перетворює рядок на вхідний потік даних InputStream;
  3. BufferedInputStream: буферизований вхідний потік. Найчастіше він використовується для підвищення ефективності.
Пам'ятаєш, ми проходили BufferedReader та казали, що його можна не використовувати? Коли ми пишемо:

BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))
…використовувати BufferedReader не обов'язково: InputStreamReader впорається із завданням. Але BufferedReader робить це ефективніше і, до того ж, уміє читати дані цілими рядками, а не окремими символами. З BufferedInputStream так само! Клас накопичує вхідні дані в спеціальному буфері без постійного звернення до пристрою введення. Розглянемо приклад:

import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.InputStream;

public class BufferedInputExample {

   public static void main(String[] args) throws Exception {
       InputStream inputStream = null;
       BufferedInputStream buffer = null;

       try {

           inputStream = new FileInputStream("D:/Users/UserName/someFile.txt");

           buffer = new BufferedInputStream(inputStream);

           while(buffer.available()>0) {

               char c = (char)buffer.read();

               System.out.println("Було прочитано символ " + c);
           }
       } catch(Exception e) {

           e.printStackTrace();

       } finally {

           inputStream.close();
           buffer.close();
       }
   }
}
У цьому прикладі ми читаємо дані з файлу на комп'ютері за адресою «D:/Users/UserName/someFile.txt». Створюємо 2 об'єкти – FileInputStream і BufferedInputStream як його «обгортку». Після цього ми зчитуємо байти з файлу і перетворюємо їх на символи. І так доти, доки файл не закінчиться. Як бачиш, нічого складного тут немає. Ти можеш скопіювати цей код і запустити його на якомусь справжньому файлі, який зберігається на твоєму комп'ютері :) Клас OutputStream – це абстрактний клас, що визначає байтове потокове виведення. Як ти вже зрозумів, це антипод InputStream. Він відповідає не за те, звідки зчитувати дані, а за те, куди їх відправити. Як і InputStream, цей абстрактний клас надає всім нащадкам групу методів для зручної роботи:
  • void close() закриває вихідний потік;
  • void flush() очищує всі буфери виведення;
  • abstract void write(int oneByte) записує 1 байт у вихідний потік;
  • void write(byte[] buffer) записує масив байтів у вихідний потік;
  • void write(byte[] buffer, int offset, int count) записує діапазон із count байтів з масиву, починаючи з позиції offset.
Ось деякі з нащадків класу OutputStream:
  1. DataOutputStream. Вихідний потік, що містить методи запису стандартних типів даних Java.

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

    
    import java.io.*;
    
    public class DataOutputStreamExample {
    
       public static void main(String[] args) throws IOException {
    
           DataOutputStream dos = new DataOutputStream(new FileOutputStream("testFile.txt"));
    
           dos.writeUTF("SomeString");
           dos.writeInt(22);
           dos.writeDouble(1.21323);
           dos.writeBoolean(true);
    
       }
    }
    

    Він має окремі методи кожного типу – writeDouble(), writeLong(), writeShort()тощо.

  2. Клас FileOutputStream. Реалізує механізм надсилання даних у файл на диску. Ми, до речі, вже використовували його в минулому прикладі, звернув увагу? Ми передали його всередину DataOutputStream, який виступав у ролі «обгортки».

  3. BufferedOutputStream. Буферизований вихідний потік. Теж нічого складного, суть та сама, що й у BufferedInputStream (або у BufferedReader). Замість звичайного послідовного запису даних використовується запис через спеціальний «накопичувальний» буфер. Завдяки буферу можна скоротити кількість звернень до місця призначення даних і за рахунок цього підвищити ефективність.

    
    ```java
    import java.io.*;
    
    public class DataOutputStreamExample {
    
       public static void main(String[] args) throws IOException {
    
           FileOutputStream outputStream = new FileOutputStream("D:/Users/Username/someFile.txt");
           BufferedOutputStream bufferedStream = new BufferedOutputStream(outputStream);
    
           String text = "I love Java!"; // цей рядок ми перетворимо на масив байтів і запишемо у файл
    
           byte[] buffer = text.getBytes();
    
           bufferedStream.write(buffer, 0, buffer.length);
           bufferedStream.close();
       }
    }
    ```
    
    

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

Також про InputStream, OutputStream та їхніх нащадків можна почитати в матеріалі «Система введення/виведення». Про FileInputStream, FileOutputStream та BufferedInputStream у нас ще буде окрема лекція, тому для першого знайомства інформації про них цілком достатньо. Ось і все! Сподіваємося, ти добре розібрався у відмінностях між інтерфейсами й абстрактними класами та готовий відповісти на будь-яке запитання, навіть із підступом!