Упс! Не вдала спроба:(
Будь ласка, спробуйте ще раз.

Протоколи Python у дії: застосування, типізація та відмінності від абстрактних класів

IT-команда NIX
IT-команда NIX
25 липня 2024 19 хвилин читання

Протоколи у Python забезпечують узгодженість та гнучкість у коді. Але як цього досягти на практиці? Пояснює Світлана Сумець, Python Software Engineer в NIX. Також порівняємо протоколи Python зі звичними для багатьох розробників абстрактними класами та дізнаємось, як вони визначають формальні інтерфейси для об’єктів.

Типізація

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

Статична та динамічна типізації

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

Для прикладу, розберемо один і той же код на Python та на Scala (ця мова має статичну типізацію). У коді на Python перший виклик функції add() виконує додавання цілих чисел і повертає число 5. Другий виклик здійснює конкатенацію рядків і повертає строку str1str2. Типи даних об’єктів визначаються автоматично при присвоєнні значень змінним. Функція add може приймати аргументи різних типів даних (у цьому випадку int та str) і повертати результат відповідного типу (знову ж таки int та str).

<br>

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

Сильна сторона статичної типізації — виявлення помилок під час компіляції коду. У мовах з динамічною типізацією проблеми проявляються лише протягом виконання коду. Хоча динамічна типізація може сприяти прискоренню створення прототипів програм і допомагати в проведенні експериментів. Це одна з причин популярності Python.

Номінальна та структурна типізації

Ні номінальної, ні структурної типізації в Python немає. Але їх все одно треба розглянути (причини розкриються трохи нижче). І почати варто з номінальної. Якщо говорити простими словами, при номінальній типізації типи збігаються, якщо мають однакові імена — навіть якщо їх структури відрізняються. Це напряму пов’язано з використанням ієрархії класів.

Повернемося до Python і Scala. У коді на Scala реалізовано абстрактний базовий клас Animal, від якого успадковано клас Cat та НЕ успадковано клас Dog. Викликаємо функцію makeSound(), що прийме як аргумент якийсь об’єкт Animal та викличе його метод sound().

З класом Cat все спрацює, бо він є типом Animal. А ось при використанні об’єкта Dog з’являється помилка про невідповідність типу. Адже очікуємо тип Animal, а отримуємо Dog, що не є типом Animal. Саме так і працює номінальна типізація. Тож результат роботи програми — у наступних рядках:

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

Код спрацює, навіть не зважаючи на те, що функція make_sound() очікує тип Animal. . Це зумовлено саме відсутністю номінальної типізації в Python.

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

Але якщо прибрати тайпхінт Animal з функції make_sound(), то ні PyCharm, ні mypy помилку вже не покажуть. Адже у Python тайпхінти лише визначають семантику номінального типу, а не реалізовують його. Також можна зазначити, що при використанні ієрархії з абстрактним базовим класом як інтерфейсом ми покладаємось саме на номінальне підтипування.

Щодо структурної типізації, то цим терміном називають підхід, де сумісність типів визначається на основі їх структури або форми даних. Знову-таки для порівняння розглянемо код двома мовами. На Scala створимо новий тип Animal з методами sound та eat та успадкуємо від нього цього разу обидва класи: і Cat, і Dog. І так само у функції makeSound() зробимо виклик sound() того об’єкта Animal, який прийде в функцію. Важливо: на відміну від попереднього прикладу тут і Cat, і Dog є типом Animal.

Але код не спрацює — саме через структурну типізацію у Scala. Вона вимагає сумісності типів на основі структури, тобто наявності у класах-послідовниках обох методів: як sound, так і eat. Тож з’являється наступна помилка при виконанні коду:

class type required but AnyRef{def sound(): Unit; def eat(): Unit} found

  class Cat extends Animal{

У Python структурної типізації немає, тому все повинно спрацювати. Створюємо новий тип Animal, успадковуємо Cat та Dog та у функції make_sound() передаємо об’єкти обох класів. І вони успішно викликають метод sound(). Адже неважливо, який саме Animal прийде у функцію, щоб код спрацював. Достатньо, щоб цей Animal мав метод sound().             

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

Качина типізація

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

Качина типізація — це концепція, згідно з якою конкретний тип об’єкта (качки) не є важливим, а важливі лише властивості та методи, якими цей об’єкт володіє. Як це було з Cat та Dog: головне, щоб був метод sound(), а все інше — не важливо. Такий підхід додає гнучкості коду та дозволяє поліморфно працювати з об’єктами, які не пов’язані один з одним і можуть бути об’єктами різних класів. Єдина умова: щоб усі ці об’єкти підтримували необхідний набір властивостей і методів (як, наприклад, метод sound()).

Підписуйтеся на наші соцмережі

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

Проте може постати питання: нащо якісь протоколи, якщо можна зробити Animal абстрактним базовим класом та зобов’язати послідовників виконувати необхідні методи (у нашому випадку sound() та eat()). Адже у цьому випадку все ніби спрацює, як слід. Ця думка не позбавлена логіки, бо протоколи та абстрактні класи є досить схожими поняттями. Проте різниця є, і про неї поговоримо далі.

Протоколи

Протокол ітератора

Навіть якщо ви не використовували протоколи в Python, ви точно чули вираз «протокол ітератора». Це поняття допоможе зрозуміти суть протоколів. Протоколи в Python — це угоди, як мають поводитися об’єкти та які методи вони мають реалізовувати. У контексті протоколу ітератора Python очікує, що об’єкти, які підтримують ітерацію, будуть реалізовувати методи __iter__() та __next__(). Однак також є зручний вбудований тип даних, який автоматично реалізує цей протокол. Це ітеровані об’єкти: списки, кортежі, рядки та словники.

На наступному прикладі зображено реалізацію ітератора для проходження по числах Фібоначчі. У випадку ітератора протокол визначає методи __iter__() та __next__(). Клас, що реалізує ці методи, вважається ітератором. Він може бути використаний у будь-якому контексті, де очікується об’єкт, що ітерується: такому як цикл for, генератор списку або функція list(). Якщо хоча б один з методів не буде реалізовано або буде реалізовано некоректно, ітератор не працюватиме. Причина: не виконано умови протоколу ітератора.

https://github.com/Sumets-Svitlana/protocols/blob/master/python/iteration_protocol.py

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

Протоколи

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

Щоб краще зрозуміти можливості протоколів, повернемося до прикладів з котами й собаками. Цього разу маємо клас протоколу, що успадковується від Protocol з модуля typing. Реалізації методів протоколу зазвичай немає. Також є клас Dog, який реалізовує всі атрибути та методи класу протоколу. А у функції make_sound() вказано, що хочемо отримати об’єкт, який відповідає протоколу Animal. Тобто він має методи sound() та eat() та атрибут category.

Якщо реалізація протоколу буде некоректною, то mypy повідомить про помилку. Наприклад, можна закоментувати метод eat() у класі Dog. І тоді після запуску mypy буде наступне повідомлення про помилку.

Важливо, що mypy не тільки повідомляє про помилку в коді, а й підказує, який метод протоколу не реалізовано або реалізовано неправильно. Для прикладу прибираємо аргумент food з методу eat() класу Dog. Через це метод протоколу буде реалізовано некоректно. Тож mypy покаже наступну помилку.

Декоратор runtime_checkable

Протоколи здебільшого не призначені для перевірок відповідності об’єктів тому чи іншому протоколу на етапі виконання програми. Щоб це стало можливим, використовують декоратор @runtime_checkable. Тобто при створенні об'єкта певного класу ви матимете можливість перевірити, чи можна розглядати об’єкт як екземпляр певного протоколу безпосередньо під час виконання програми.

Як це працює, видно на наступному прикладі. Тут є клас протоколу та клас, що реалізовує цей протокол. Без використання декоратору @runtime_checkable з’явиться помилка виконання коду:

TypeError: Instance and class checks can only be used with @runtime_checkable protocols

З декоратором можна перевіряти відповідності типів у момент виконання програми.

Є цікавіший приклад. Наприклад, у нас є протокол, що зобов’язує реалізувати метод __len__. Перевірка виконується для рядка та списку, які мають цей метод. Тож результатом буде True.

Ієрархія протоколів

Як і звичайні класи, протоколи можуть бути в ієрархії, але лише за певних умов:

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

Абстрактні базові класи проти протоколів

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

Переваги:

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

Недоліки:

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

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

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

Що тут важливо знати:

  • Абстрактні базові класи успадковуються від ABC з модуля abc, у той час, як протоколи — від Protocol з модуля typing.
  • Зазвичай протоколи не мають реалізації методів за замовчуванням. А для абстрактних класів це стандартна практика. Вони часто використовуються зокрема й для реалізації спільної для класів-послідовників логіки.
  • Абстрактні класи покладаються на номінальну підтипізацію, а протоколи — на структурну. Це те, про що йшла мова на самому початку статті. Для номінальної типізації характерне визначення типу даних згідно з їхніми іменами, а не вмістом або структурою. У ієрархії з абстрактним класом буде помилка від mypy, якщо Dog не буде успадковуватися від Animal. Тобто функція make_sound() не зможе отримати тип, який очікує. А ось із протоколами такої проблеми немає, хоча Dog явно не успадковується від класу-протоколу. У прикладі з протоколами буде помилка від mypy, якщо не виконано правило структурної типізації: типи збігаються, якщо мають однакові структури або форми даних. Тобто помилка буде, якщо прибрати, наприклад, метод sound() з класу Dog. Хоча за таких самих дій з ієрархією класів помилки не буде.
  • Абстрактні класи не можна застосовувати заднім числом до наявних класів. Якщо вже є окремий, ні від чого не успадкований клас Dog, який треба зробити сумісним з абстрактним базовим класом Animal, то необхідно змінити ієрархію класів і явно успадковувати від Animal. Протоколи ж дозволяють вказати очікувані методи та атрибути, які повинен мати клас, без вимоги явного успадкування або змін в ієрархії класів.
  • Перевірки відповідності об’єктів тому чи іншому інтерфейсу на етапі виконання програми. Для ієрархії з абстрактним класом все буде працювати, як ми звикли (isinstance(Dog(), Animal)). А для перевірки відповідності об'єкта певному протоколу слід написати декоратор @runtime_checkable.
  • Протоколи можна використовувати для визначення інтерфейсів для вбудованих типів, сторонніх бібліотек або класів, які не перебувають під вашим контролем. Тобто клас Dog може не бути у підконтрольному коді — припустимо, це клас зі сторонньої бібліотеки, який треба використати. Після створення протоколу та зазначення, що ви очікуєте у функції make_sound() об’єкт, який підпорядковується цьому протоколу, ви явно вкажете, що очікуєте щось, що точно матиме метод sound(). І це вже не буде чорним ящиком! Тобто не потрібно думати, що ж там прийде з цієї сторонньої бібліотеки. Це особливо цінно при неможливості зміни наявних класів або їхньої ієрархії успадкування (сторонні бібліотеки чи класи не є підконтрольним нам кодом, його неможливо змінити).

У цьому випадку абстрактні класи програють, бо з ними неможливо зробити наступне:

Коли використовувати протоколи в Python

  • Якщо потрібна гнучкість та адаптивність під час визначення інтерфейсів

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

  • Якщо необхідно зробити наявні класи сумісними з інтерфейсом

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

  • Якщо треба визначити інтерфейси вбудованих типів або сторонніх бібліотек без зміни коду

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

Також раджу почитати по темі:

Підписуйтеся на наші соцмережі

Якщо ви хочете поділитися з читачами SPEKA власним досвідом, розповісти свою історію чи опублікувати колонку на важливу для вас тему, долучайтеся. Відтепер ви можете зареєструватися на сайті SPEKA і самостійно опублікувати свій пост.
50 UAH 150 UAH 500 UAH 1000 UAH 3000 UAH 5000 UAH
0
Прокоментувати
Інші матеріали

Kharkiv IT Cluster про вступну кампанію 2024: результати вступу на ІТ-спеціальності

Olga Sukhorukova 6 вересня 2024 17:46

Як навчити дитину програмувати. Досвід СЕО дитячої ІТ-академії та батька трьох дітей

Вячеслав Поліновський 6 вересня 2024 14:57

Ринок IT-вакансій відзначився рекордним серпнем

Анастасія Ковальова 4 вересня 2024 16:09

Як Open Data Accelerator керується принципами непрозорості та гальмує інновації

Вадим Куликов 2 вересня 2024 14:20

Айтівці оцінили, наскільки складно зараз шукати роботу в ІТ

Вікторія Рудзінська 29 серпня 2024 22:38