Интерфейс vs interface

    Одним из принципов объектно-ориентированного проектирования является программирование на уровне интерфейса, а не на уровне реализации. Видимо, из-за того что код в книгах и статьях по проектированию представлен преимущественно на 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())
    • +19
    • 8,5k
    • 7
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 7
    • +3

      Кажется, что пример про UserCollection получился не python-way. Или мне одному в коллекции хочется видеть стандартный интерфейс __contains__, а не includes?

      • +1

        Спасибо за замечание, __contains__ здесь действительно лучше подходит. Добавил в UPD

      • 0

        Абстрактные классы в Python — отличный вариант для обобщённой работы с несколькими API-сервисами, предоставляющими по сути одну и ту же информацию, но в рамках собственной реализации.
        В этом случае абстрактный класс-родитель содержит общие для всех его конкретных классов-потомков абстрактные методы, реализуемые в каждом конкретном классе-потомке по-своему (работа с JSON, SOAP и др.), но при этом возвращающие одни и те же структуры данных с одинаковыми ключами, типами данных и т.п.
        При этом в работе бэкенда совершенно не важно, какой конкретно дочерний класс инстанцируется "под капотом".

        • –2
          В питоне принцип «клиенту не нужно иметь информации о конкретных типах объектов, которыми он пользуется, при условии, что все они имеют ожидаемый клиентом интерфейс;» реализован by default. Утиная типизация, всё такое.

          Если у нас есть .get, то нам всё равно у какого он типа. Если у метода есть .next(), то мы можем его итерировать.

          Пример с is_password_valid выглядит очень уродливо. Для таких вещей есть @staticmethod, а функция нас не «обманывает», так как в нормальном режиме (как член класса) она имеет ещё и self в аргументах. Если же self не нужен (для удобства тестирования) — то как раз @staticmethod поможет.

          90% процентов джавовских паттернов разработки — всего лишь борьба с уродствами самой java. В питоне оно из коробки или реализуется в пол-пинка. В том числе опциональные аргументы, keyword-аргументы, или вот это, вот, с «интерфейсами».
          • 0

            Вот с этим соглашусь, а вот что написал автор в статье, я не особо-то и понял. Как-то не связанно и сумбурно повествует.

            • +1

              Например, у нас имеется простой класс:


              class ColourMix:
                  def __init__(self, colours):
                      self.colours = colours
              
                  def print_colours(self):
                      for colour in self.colours:
                          print(f"Colour RGB: ({colour.red}, {colour.green}, {colour.blue})")

              При разработке другой части приложения клиент не хочет раскрывать реализацию и опирается только на интерфейс. Но интерфейс не говорит ничего о том, чем же является colours, и что он содержит внутри. Тогда единственным способом работы с данным классом является чтение его реализации и поиск всех методов, которые он дергает у colours и элементов colours. Это неэффективно и замедляет разработку. Если явно сказать, что colours — это список, содержащий объекты-значения, то в реализацию не нужно раскрывать:


              from typing import NamedTuple, List
              
              class Colour(NamedTuple):
                  red: int
                  green: int
                  blue: int
              
              class ColorMix:
                  def __init__(self, colours: List[Colour]):
                      self.colours = colours
              
                  def print_colours(self):
                      for colour in self.colours:
                          print(f"Color RGB: ({colour.red}, {colour.green}, {colour.blue})")

              Но при этом мы не лишаемся возможностей, которые предоставляет нам утиная типизация, только теперь нам известно, что для работы ColourMix нужен объект, который реализует интерфейс списка, причем не обязательно наследованием от списка.


              Если у нас есть .get, то нам всё равно у какого он типа. Если у метода есть .next(), то мы можем его итерировать.

              Это, конечно, верно. Статья о том, что интерфейс объекта должен явно говорить о наличии .get или .next.


              Для таких вещей есть @staticmethod, а функция нас не «обманывает», так как в нормальном режиме (как член класса) она имеет ещё и self в аргументах.

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


              from utils import DatabaseConfig
              # DatabaseConfig предоставляет доступ к конфигу,
              # который хранится в БД
              
              class PasswordUtils:
                  def __init__(self):
                      self.min_length = DatabaseConfig().password_min_length
              
                  def is_password_valid(self, password: str) -> bool:
                      return len(password) > self.min_length

              Инстанцирование класса в "неправильном" окружении снова возбудит ошибку. Функция или метод класса — это инструменты для демонстрации примера внешней зависимости, которая раскрывает реализацию. Суть примера не в применяемых инструментах, а в необходимости явной передачи всех внешних зависимостей.


              Паттерны разработки — это борьба со сложностью программ. Если в приложении в нескольких местах есть повторяющийся код инстанцирования и настройки объекта, я вынесу его в фабрику — единое место для получения "готового к работе" инстанса — и не важно, на каком языке я программирую. Необходимость инкапсулировать сложное создание объекта не зависит от языка, а вот реализация зависит. Паттерны рассказывают о том, как увидеть эту необходимость, а не о том, какой конкретно код нужно писать.

              • 0
                Я не могу понять ваш пример, потому что он не похож на питон код, и python3 со мной в этом согласен.

                Второй пример, с password_min_length — либо у нас это статичное, и мы пишем self.min_length = DatabaseConfig.password_min_length, либо, если это берётся из базы данных, то мы пишем так:

                def __init__(self, db):
                self.min_length = db.password_min_length


                Но я всё ещё не понимаю, при чём тут паттерны. Лично у меня от всего этого есть ощущение, что для борьбы со сложностью авторы разводят ещё большую сложность.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.