Интерфейс vs interface
- вторник, 1 августа 2017 г. в 03:13:46
Одним из принципов объектно-ориентированного проектирования является программирование на уровне интерфейса, а не на уровне реализации. Видимо, из-за того что код в книгах и статьях по проектированию представлен преимущественно на Java, программисты на других языках, особенно с динамической типизацией, испытывают трудности с переносом знаний из этих книг и статей на свой рабочий язык программирования.
Часто сложность в понимании принципа "программируйте на уровне интерфейса" кроется в концентрации на инструменте, а не на смысле. Из-за наличия в Java ключевого слова interface
, происходит искажение понимания принципа, и он превращается в "программируйте, используя interface
". Так как в Python инструмент в виде ключевого слова interface
отсутствует, некоторые питонисты пропускают этот принцип.
В книге Банды Четырех примеры приводятся на Smalltalk и C++. Оба этих языка не имеют ключевого слова interface
, но это не мешает авторам применять принцип, используя имеющиеся в распоряжении конструкции языка:
У манипулирования объектами строго через интерфейс абстрактного класса есть два преимущества:
- клиенту не нужно иметь информации о конкретных типах объектов, которыми он пользуется, при условии, что все они имеют ожидаемый клиентом интерфейс;
- клиенту необязательно "знать" о классах, с помощью которых реализованы объекты. Клиенту известно только об абстрактном классе (или классах), определяющих интерфейс.
Данные преимущества настолько существенно уменьшают число зависимостей между подсистемами, что можно даже сформулировать принцип объектно-ориентированного проектирования для повторного использования: программируйте в соостветствии с интерфейсом, а не с реализацией.
Но даже приведенные в цитате преимущества не являются единственными, если посмотреть на принцип под более широким углом.
Самое общее определение интерфеса из русскоязычной Википедии выглядит так:
Интерфейс — "общая граница" между отдельными системами, через которую они взаимодействуют; совокупность средств и правил, обеспечивающих взаимодействие отдельных систем.
Человек является системой, а значит каждый из нас ежедневно сталкивается со множеством интерфейсов для взаимодействия с другими системами. Примеры интересных интрефейсов в реальном мире можно посмотреть в постах от Мосигры (раз, два, три, четыре). Взаимодействие с хорошим интерфейсом происходит без лишних хлопот, мы не замечаем как используем его. Более того, взаимодействуя даже с самым ужасным интерфейсом, мы обращаем внимание только на неудобство интерфейса, в то время как реализация в обоих случаях скрыта от наших глаз.
При работе с микроволновой печью мы пользуемся совокупностью средств: элементы управления для включения печи, установки времени, мощности, режима разогрева. А также совокупностью правил: чтобы блюдо разогрелось, нужно поставить его внутрь печи, закрыть дверцу и запустить разогрев. Согласитесь, если бы для разогрева обеда нам требовалось изменять электрическую схему печи, настраивая тем самым частоту работы магнетрона, это доставляло бы неудобство. Управление печью при помощи интерфейса позволяет нам не только уменьшить время и трудозатраты для достижения конечной цели — разогреть обед, но и дает возможность использовать знания интерфеса при работе с микроволновыми печами других видов и от других производителей.
Концептуально, смысл интерфейсов в программном коде не отличается от такового в реальном мире. Интерфейс — это все таже "общая граница" между отдельными системами. Только в данном случае системы — это сервисы, микросервисы, пакеты, классы или даже функции. Каждая из этих единиц программного кода является системой со своей границей, через которую необходимо взаимодействовать и которую не нужно нарушать.
Описанная выше ситуация о необходимости изменения электрической схемы микроволновой печи для разогрева обеда кажется абсурдной. Но программисту приходится сталкиваться с подобным в ежедневной практике. Даже плохое название метода, которое не выражает его намерения, может привести к раскрытию реализации:
class UserCollection:
# Код инициализации пропущен
def linear_search_for(user: User) -> bool:
for saved_user in self._all_users:
if saved_user == user:
return True
return False
Такое название говорит нам об использованном внутри алгоритме, а также о структуре данных, которая лежит в основе UserCollection
. Вся эта информация является лишней на данном уровне абстракции, плохо передает намерения и неудобна для дальнейшего расширения. Чтобы сделать интерфейс более чистым, выразим в имени метода "что" делает код, а не "как" он это делает:
class UserCollection:
# Код инициализации пропущен
def includes(user: User) -> bool:
''' Любая необходимая реализация '''
Отражающие суть названия вносят большой вклад в возможность программировать на уровне интерфейса, но только названий недостаточно для реализации принципа. Например, эту простую функцию, имеющую понятное название, сложно использовать, зная только интерфейс:
from utils import DatabaseConfig
# DatabaseConfig предоставляет доступ к конфигу,
# который хранится в БД
def is_password_valid(password: str) -> bool:
min_length = DatabaseConfig().password_min_length
return len(password) > min_length
Интерфейс обманывает нас, заявляя, что для работы достаточно только пароля. Вызов этой функции в окружении, где необходимая база данных не поднята, приведет к ошибке, что заставит обратиться к реализации. Обогатим интерфейс, чтобы избавиться от необходимости обращаться к реализации. В данном случае подойдет явная передача параметра min_length
:
# Зависимость от DatabaseConfig больше не нужна
def is_password_valid(password: str, min_length: int) -> bool:
return len(password) > min_length
Неявная зависимость от DatabaseConfig
устранена. В дополнение к этому мы получили пригодную для тестирования функцию, которая представляет из себя настоящий черный ящик со всеми необходимыми входными параметрами и известным типом выходного параметра.
Неявные зависимости раскрывают реализацию любой единицы программного кода. Python позволяет писать код, который будет исполнен при импорте файла. На первый взгляд это может показаться безобидным, но создаваемая таким образом неявная зависимость влечет за собой много проблем:
# utils.py
class DatabaseConfig:
''' Инициализация класса создает соединение с базой данных '''
config = DatabaseConfig()
# В этом же файле лежит функция с хорошим интерфейсом
def is_password_valid(password: str, min_length: int) -> bool:
return len(password) > min_length
# user.py
from utils import is_password_valid
# В момент импорта происходит инициализация
# DatabaseConfig и подключение к БД
class User:
def __init__(self, name: str, password: str):
self.name = name
self.password = password
def change_password(self, new_password: str) -> None:
if not is_password_valid(new_password, min_length=6):
raise Exception('Invalid password')
self.password = new_password
Импорт класса User в интерпретатор или тест, если необходимая БД не запущена, снова закончится ошибкой, которая раскрывает реализацию. Изменять интерфейс класса нет смысла, так как причиной бед в данном случае является выражение from utils import is_password_valid
, а именно глобальная переменная, которая создается во время импортирования. Еще один недостаток глобальной переменной — она может стать причиной невозможности программировать на уровне интерфейса. Решить возникшую проблему можно с помощью создания экземпляра DatabaseConfig
в момент старта приложения и явной передачи экземпляра всем заинтересованным объектам.
Отсутствие неявных зависимостей и отражающие суть названия, защищая нас от деталей реализации, все еще не позволяют получить все преимущества программирования на уровне интерфейса. Самое время обратиться к программированию строго через интерфейс абстрактного класса. Кент Бек в книге "Smalltalk Best Practice Patterns" пишет:
There are a few things I look for that are good predictors of whether a project is in good shape.
…
Replacing objects — Good style leads to easily replaceable objects. In a really good system, every time the user says “I want to do this radically different thing,” the developer says, “Oh, I’ll have to make a new kind of X and plug it in.”
Использование интерфейса, определенного абстрактным классом, вместо конкретного класса — это удобный прием для создания заменяемых объектов. В Python есть возможность создавать абстрактные классы при помощи модуля стандартной библиотеки abc, но для компактности кода в примерах будет использоваться подход, когда нереализованные методы абстрактного класса выбрасывают NotImplementedError
.
Допустим, нам необходимо реализовать отображение прогноза погоды на сегодня и на текущую неделю. Прогноз погоды мы получаем с некоторого стороннего ресурса. Чтобы не привязываться к конкретному ресурсу, а также к возвращаемому ресурсом формату данных, нужно формализовать способ общения в виде абстрактного класса, а формат данных в виде объекта-значения:
# weather.py
from typing import List, NamedTuple
class Weather(NamedTuple):
max_temperature_с: int
avg_temperature_с: int
min_temperature_c: int
class WeatherService:
def get_today_weather(self, city: str) -> Weather:
raise NotImplementedError
def get_week_weather(self, city: str) -> List[Weather]:
raise NotImplementedError
Не имея конкретной реализации, клиент нашего кода, опираясь на предоставленные интерфейсы, уже сможет начать тестирование и разработку, используя вместо реального сервиса подменные объекты:
# test.py
from client import WeatherWidget
from weather import Weather, WeatherService
class FakeWeatherService(WeatherService):
def __init__(self):
self._weather = Weather(max_temperature_с = 24,
avg_temperature_с = 20,
min_temperature_c = 16)
def get_today_weather(self, city: str) -> Weather:
return self._weather
def get_week_weather(self, city: str) -> List[Weather]:
return [self._weather for _ in range(7)]
def test_present_today_weather_in_string_format():
weather_service = FakeWeatherService()
widget = WeatherWidget(weather_service)
expected_string = ('Maximum Temperature: 24 °C'
'Average Temperature: 20 °C'
'Minimum Temperature: 16 °C')
assert widget.today_weather == expected_string
Интерфейс дает нам гибкость: если наших пользователей не устроит точность прогноза, мы сможем легко переключиться на другой ресурс прогноза погоды, написав класс, реализующий интерфейс WeatherService
.
Использование принципа "программируйте в соответствии с интерфейсом, а не с реализацией" позволяет создавать более гибкий дизайн приложения, разгрузить голову разработчика и улучшить коммуникации внутри команды. Все это делает систему более пригодной для поддержки и добавления новой функциональности. В Python нет ключевого слова interface
, но есть другие способы реализации принципа: отражающие суть названия, устранение неявных зависимостей и использование абстрактных классов. Давайте чаще обращать внимание на суть принципов, лежащих в основе хорошего кода, а не концентрировать все свое внимание на инструментах.
UPD
pacahon предложил python-way интерфейс для UserCollection
class UserCollection:
# Код инициализации пропущен
def __contains__(user: User) -> bool:
''' Любая необходимая реализация '''
Метод __contains__
позволяет проверять принадлежность элементов с помощью in
и not in
. Если использовать type-hints в интерфейсе __contains__
, PyCharm подскажет, что int
в данном случае является неподходящим типом:
print(1 in UserCollection())