Усе є об’єктом.


Розширені атрибути

Дехто стверджує, що Python строго типізований, бо кожен об’єкт у Python має тип, яким є клас або метаклас, як зазначено в __class__. Однак це дуже слабка форма типізації, оскільки її, як і багато чого іншого в Python, легко змінити. Усе, що потрібно зробити, — присвоїти інший тип __class__.

Так само про змінні часто кажуть, що вони динамічно типізовані, але, оскільки вони можуть посилатися на будь-який об’єкт Python, точніше буде сказати, що вони не типізовані.

У цьому розділі ми розглянемо зв’язок між класом і типом та те, як Python використовує цей зв’язок.




Три типи типів

Перш ніж починати, важливо зрозуміти, що слово «тип» у програмуванні має кілька значень.

Найпоширеніше вживання цього слова означає примітивний тип даних. Друге значення слова «тип» — об’єкт-визначення.

Що саме означають ці два значення терміна «тип», докладніше розповімо пізніше. Поки що припускається, що ви приблизно уявляєте, що таке примітивний тип, наприклад int або рядок, і маєте уявлення про тип на основі класу, тобто екземпляр класу.

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

Подібні ідеї типізації даних є частиною чистого функційного програмування в таких мовах, як Haskell. Про них можна багато говорити, але це не зовсім мейнстрим у сенсі таких мов, як Python, jаvascript, Java, C++ тощо.

У решті цього розділу під типом розумітимемо або примітивний тип даних, або тип на основі класу. Але ми все одно маємо точно сказати, що це таке.




Примітивні типи даних

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

Ви могли написати:

a=3
b=3.14159
и:
c="Hello World"

і ці значення 3, 3.14159 та «Hello World» було б перетворено на певне внутрішнє представлення й збережено у відповідних змінних.

Зберігання значень у змінній веде до того, що називається семантикою значень, яку значною мірою витіснила семантика посилань, що використовується в Python. За семантики значень змінна зберігає значення, з яким працюють; за семантики посилань значення зберігається деінде, а змінна зберігає посилання на нього.

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

a=3
число 3 зберігатиметься як двійкове ціле число — рядок нульових бітів, що закінчується на 011. Число:

b=3.14159
зберігатиметься в цілком іншому форматі — зазвичай у форматі з рухомою комою. Різниця між цими двома форматами полягає в тому, що формат із рухомою комою може показати, де розташована десяткова крапка, а ціле число — ні.

Обидва — числа, але для комп’ютера це різні типи чисел.

Тонший нюанс, який часто збиває з пантелику новачків, такий:

c="3"

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

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

Їхню філософію можна підсумувати так:

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

Досвідченому програмісту це здається кроком назад, але це не так. Чому пересічний програміст має перейматися деталями представлення та зберігання даних? Це крок у бік абстракції: вважати числом усе, що має вигляд числа, і навіть не розрізняти різні типи чисел.

Звісно, ці відмінності й далі існують, але компілятор має впоратися з ними й захистити нас від них.

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

Цілі числа Python особливі тим, що можуть мати довільну кількість цифр. Ви можете використати:

a=123456789012345678901234567890
print(a*2)

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

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

Так само числа з рухомою комою в Python реалізовано так, щоб вони відповідали апаратному забезпеченню або операційній системі. Зазвичай це числа з рухомою комою подвійної точності стандарту IEEE.

Варто зазначити, що Python не розрізняє примітивні типи даних та інші типи. Пам’ятайте: у Python усе є об’єктом, і все має рівні права.

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

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

Наприклад, можна виконувати арифметичні операції над числами, але не над символами, які просто схожі на цифри.

Простіше кажучи, тип визначає операції, які можна виконувати.

Оголошуючи тип об’єкта, ви точно вказуєте, які операції можна виконувати та які методи можна викликати.




Тип на основі класу

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

У більшості мов на основі класів класи вважаються типами, а не об’єктами.

У мові на основі класів оголошення класу також створює новий тип даних.

Тобто:

Class MyClassA(){
   //множество свойств
}
не лише створює новий клас, а й додає новий тип даних MyClassA до системи типів.

У цій системі типів об’єкти мають визначений тип, а змінні потрібно оголошувати як змінні визначеного типу. Змінна може посилатися лише на об’єкти цього типу.

Тепер, коли ви оголошуєте змінну типу:

MyClassA myVariable;

система знає, на що посилається myVariable.

Це дає системі змогу перевірити, що коли ви пишете:

myVariable.myProperty

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

На відміну від Python або будь-якої іншої нетипізованої мови, де myVariable може посилатися на будь-який об’єкт, а отже, ви не можете зробити висновок, що:

myVariable.myProperty

є коректним.

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

Зверніть увагу: тип існує у двох варіантах — змінна має оголошений тип, а об’єкт має визначений тип.

У найпростішому випадку сильна типізація просто забезпечує дотримання правила, за яким змінна може посилатися лише на об’єкт оголошеного типу.

Тобто екземпляр класу має тип, і посилатися на нього може лише змінна такого самого типу.




Ієрархічна типізація

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

Наприклад:

Класс MyClassB:наследует MyClassA{

   //множество свойств

}

Тепер MyClassB має всі властивості MyClassA, а також те, що додано до його визначення.

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

Зрештою, типізація потрібна, щоб переконатися, що об’єкт має властивості, які ви використовуєте. Екземпляр MyClassB має всі властивості MyClassA, тож його можна вважати таким самим. MyClassB також є підтипом MyClassA в тому сенсі, що він водночас є MyClassA і новим типом сам по собі.


Тому в більшості строго типізованих мов на основі класів цілком нормально писати такі речі, як:

MyClassA myObject=new MyClassB();
і далі використовувати будь-які властивості, що належать MyClassA.

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

Якщо припуститися помилки й спробувати використати властивість, якої немає в MyClassA, компілятор повідомить про помилку під час компіляції, і ви уникнете помилки під час виконання.



Чому ієрархічна типізація корисна?

Вона корисна, бо дає змогу писати частково узагальнені методи.

Наприклад, припустімо, у вас є ієрархія класів Animal та два підкласи Cat і Dog. Якщо ви хочете використовувати лише методи й властивості класу Animal, то за допомогою ієрархічної типізації можна написати метод, який приймає Animal як параметр і працює з об’єктами типу Animal, Cat або Dog.

Коли ви пишете метод, що працює з типом, він також працює з усіма його підтипами.

У більшості мов є один найвищий супертим, від якого походять усі інші типи. Зазвичай він називається Object або якось подібно. Це можна використати для написання цілком універсальних методів, оскільки змінна типу Object може посилатися на будь-що. Однак пам’ятайте: через сильну типізацію можна використовувати лише методи, наявні в Object.




Успадкування як модель

Навіщо взагалі використовувати успадкування і чому воно пов’язане з ідеєю підтипів?

Це складне питання, яке викликає чимало суперечок. Ми вже розглядали ці ідеї в розділах 11 і 12, але вони також пов’язані з поняттям типу.


Початкова ідея використання об’єктів полягала в моделюванні реального світу. У реальному світі речі є об’єктами з властивостями й навіть методами, за допомогою яких об’єкт може щось робити. Впровадження об’єктів у програмування мало зробити його більше схожим на вправу з моделювання. Справді, першою об’єктно-орієнтованою мовою була Simula — мова для моделювання.

Ідея полягає в тому, що в реальному світі об’єкти пов’язані між собою. Проблема в тому, що ці зв’язки складні.

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

Повторне використання коду мало що говорить про відносини між типами. Спокусливо зробити наступний крок і сказати, що якщо об’єктВ успадковує об’єктА, то він є об’єктомА, а також об’єктомВ. Квадрат — це квадрат, але він також і прямокутник, наприклад.

Найвідоміше втілення цієї ідеї — принцип підстановки Лісков. Він стверджує, що скрізь, де можна використати екземпляр класу, можна використати екземпляр будь-якого підкласу. Оскільки його можна використати замість базового класу, він має бути підтипом базового класу. Це пояснюється тим, що підклас має всі методи базового класу.

Часто це правда, але не завжди.

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

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


Це також робить сувору типізацію довільним теоретичним рішенням, коли йдеться про правила використання екземплярів класів. Можна знайти способи змусити підкласи завжди працювати як підтипи. Наприклад, якщо ви реалізуєте квадрат як прямокутник, у якого все одно вказано дві сторони, ви можете зберегти всі методи прямокутника й забезпечити рівність іншим способом. Це далеко не природно.
Також є проблема, пов’язана з тим, що в реальному світі об’єкти пов’язані з багатьма іншими об’єктами. Квадрат є окремим випадком прямокутника й водночас є рівносторонньою фігурою з n сторонами.

Можна спробувати змоделювати це за допомогою множинного успадкування в Python, але зазвичай це набагато складніше, ніж здається на початку роботи. Саме тому більшість інших мов обмежуються одиничним успадкуванням. Інші мови додають до класів ідею інтерфейсів — по суті, оголошень класів без реалізації. Це дає змогу використовувати обмежену форму множинного успадкування, але ніяк не допомагає повторно використовувати код і змушує програмістів повертатися до методу «копіюй і вставляй».

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

Це головна причина, чому можна почути пораду на кшталт «віддавайте перевагу композиції, а не успадкуванню». Ідея про те, що один об’єкт містить інший, у багатьох сенсах є простішою для роботи. Так, наприклад, об’єкт автомобіля може містити об’єкт керма й чотири об’єкти дорожніх коліс, які, своєю чергою, містять об’єкти коліс. Однак це не завжди доречно: наприклад, квадрат може містити об’єкт прямокутника, а наявні мови погано підтримують композицію.

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

У багатьох об’єктно-орієнтованих мовах визначення класу вводить у систему типів новий тип.

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

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

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

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

У Python змінні не мають пов’язаного з ними типу й можуть посилатися на об’єкти будь-якого типу.

У Python об’єкти мають обмежене поняття типу, оскільки їхній атрибут __class__ встановлено на клас або метаклас, який їх створив. Однак важливо розуміти, що __class__ можна змінити.

Ви можете використовувати isinstance і issubclass, щоб перевірити, чи об’єкт стверджує, що належить до відповідного типу.

Інший підхід — використовувати захисне програмування й за допомогою функції hasattr перевіряти наявність будь-якого атрибута чи методу, яким ви плануєте скористатися. Зазвичай це називають «качиним типізуванням».