«Программа для интерфейсов, а не для реализации». Эту фразу наверняка слышал каждый программист на каком-то этапе своей карьеры. Но что это значит, почему это важно и как это может улучшить качество кода? В этом посте я хотел бы представить высокоуровневое, не зависящее от языка введение в интерфейсы, а затем дать один простой совет, который сделает вашу работу с интерфейсами удовольствием - пусть они будут небольшими!

Введение в интерфейсы

Хотя есть небольшие вариации концепции в зависимости от языка программирования, интерфейс - это абстрактный тип, который не содержит данных или кода, но определяет поведение как сигнатуры методов. В C ++ он называется чистым абстрактным классом, в Objective-C и Swift - протоколом, в большинстве других объектно-ориентированных языков он просто называется интерфейсом. Интерфейсы были разработаны как механизм для объявления того, что один тип может реализовывать множество, часто не связанных поведения.

Интерфейсы, которые часто рассматриваются как особенность объектно-ориентированного программирования, можно найти и в функциональном мире. Haskell (скомпилированный, строго типизированный чистый функциональный язык) имеет классы типов, которые в сочетании с системой типов Хиндли-Милнера обеспечивают надежное специальное множественное наследование. В Elixir (скомпилированный слабо типизированный функциональный язык) есть протоколы, которые достигают полиморфизма через форму сопоставления с образцом. Эти механизмы служат почти для той же цели, что и интерфейсы в Java или Go, поэтому для целей этой статьи мы можем рассматривать их как одно и то же.

Чем полезны интерфейсы

Для начинающего программиста, который просто работает над небольшими проектами и хочет, чтобы работа выполнялась быстро, интерфейсы - ненужная академическая концепция. Если им нужен полиморфизм в объектно-ориентированном языке, они обычно идут на наследование. И все же, как вы смоделируете кошку, которая может быть либо домашним животным, либо хищником, в зависимости от того, являетесь ли вы его человеческим компаньоном или потенциальной добычей?

Если в языке реализовано множественное наследование, например в C ++ или Python, ваш класс Cat может напрямую наследовать от суперклассов Pet и Predator и прекратить работу. И все же множественное наследование считается запутанным, и опытные программисты не одобряют его из-за проблем, подобных легендарному алмазу смерти.

Вы можете технически использовать одиночное наследование, но для всех, кроме самых тривиальных случаев, это потребует массивных, запутанных деревьев наследования, которых большинство из нас хотели бы избежать, чтобы сохранить свое здравомыслие. Только подумайте, от чего CatPet, и Predator) должен наследовать? Я делаю ставку на PredatoryMammalPet - суперкласс, в котором размещаются хорьки и ласки, но не питоны, которые естественным образом унаследуются от PredatoryReptilianPet. Но давайте не будем идти по этому пути; за гранью лежит безумие.

Используя интерфейсы, Cat может реализовать интерфейсы Pet и Predator. В отличие от наследования, именно Cat или один из его суперклассов должен предоставить фактическую реализацию. Другими словами, Кот должен определить свой особый способ быть милым (как Домашнее животное) и охотиться (как Хищник).

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

Чтобы проиллюстрировать этот момент, давайте представим приложение, поддерживаемое некоторым хранилищем данных. Приложение представляет собой простую банковскую систему, в которой хранятся только транзакции. Хранилищу данных нужно будет выполнить только одно из трех: перечислить все транзакции, найти транзакцию по ее уникальному идентификатору и сохранить новую транзакцию. Его можно смоделировать с помощью такого интерфейса (на этот раз в Go):

Первоначально ваше приложение запускается с использованием локального экземпляра MySQL, а реализация хранилища данных представляет собой несколько простых SQL-запросов и преобразование строки в объект в стиле ORM. Однажды один из крупных игроков приобретает вашу компанию, и внезапно вашему бедному маленькому хранилищу данных требуется обрабатывать трафик, который на несколько порядков больше. Ваша база данных MySQL больше не годится. К счастью, вы все это время программировали интерфейс, поэтому, потратив час на замену mySQLDatastoreImplementation на hBaseDatastoreImplementation, вы можете пойти домой, чтобы отдохнуть и одеться.

Это изменение было не только легко реализовать, но и легко идентифицировать. Благодаря интерфейсу Datastore первоначальный разработчик отделил все проблемы сохранения приложения от остальной части приложения. Просто взглянув на список интерфейсов, вы знали, какой элемент нужно изменить, и не беспокоились о том, что, изменив его, вы сломаете что-то еще. Поскольку остальная часть приложения обращается к уровню сохраняемости только через интерфейс Datastore, никакая другая подсистема никогда не узнает об изменении механизма хранения.

И последнее, но не менее важное: интерфейсы улучшают тестируемость вашего кода. После того, как конкретная реализация Datastore будет внутренне протестирована с ее механизмом поддержки, другим подсистемам не будет смысла запускать свои тесты с реальной базой данных - фиктивная реализация будет работать нормально. Благодаря этому маневру большинство тестов не попадут в базу данных, что приведет к значительному повышению производительности и устранению большого количества нестыковок, связанных с использованием общего ресурса (фактической базы данных) для нескольких независимых тестов.

Сохраняя их маленькими

Если вы программировали на Java или аналогичном языке, вы, вероятно, слышали о SOLID. Это аббревиатура пяти основных принципов объектно-ориентированного программирования и дизайна - пяти заповедей хорошего программиста ООП. I в SOLID обозначает принцип разделения интерфейса (ISP). В нем говорится, что клиентов не следует заставлять зависеть от методов, которые они не используют. Он советует программисту разбивать большие интерфейсы на более мелкие и более конкретные, чтобы клиенты знали только о методах, которые их интересуют. Такие сжатые интерфейсы также называются ролевыми интерфейсами, чтобы отразить тот факт, что в мире небольших интерфейсов один класс часто играет более одной роли.

Чтобы проиллюстрировать этот принцип на практике, вернемся к нашему примеру с банковским приложением. Ранее мы предполагали, что приложению нужно знать только о транзакциях, но однажды генеральный директор предлагает революционную концепцию учетных записей и дает себе хороший бонус. А теперь дело за вами - воплотить это изобретение в жизнь. Наивный подход заставил бы вас привязать операции, связанные с учетной записью, к интерфейсу Datastore:

Обратите внимание, как нам пришлось изменить имена существующих методов, связанных с транзакциями, чтобы избежать путаницы. При этом мы изменили существующий интерфейс, и нам нужно будет заменить соответствующие методы во всей нашей кодовой базе. Насколько было бы проще, если бы первоначальный дизайнер думал о хранилище данных модульно, используя что-то вроде шаблона проектирования репозитория:

Небольшие интерфейсы поощряют использование, поскольку стоимость реализации минимальна, а накладные расходы минимальны. Чтобы проиллюстрировать магию, которую могут вызвать небольшие интерфейсы, позвольте мне представить два примера - io.Reader в Go и Monad в Haskell.

io.Reader - это интерфейс, который реализуется всеми источниками байтовых данных. Он прост и содержит всего один метод:

Метод Read берет кусок (приблизительный эквивалент Array на других языках) байтов p заданного размера и заполняет его своим байтом данные. Он изменяет состояние p, а также возвращает количество байтов, записанных в p, и ошибку, если таковая имеется. Многие вещи в стандартной библиотеке Go реализуют этот интерфейс, включая файлы и сетевые сокеты. Также несложно предоставить свои реализации и настройки. Например, давайте прочитаем файл, изменим некоторые отдельные байты и запишем содержимое в другой файл. Мы можем начать с реализации объекта ByteChangingReader:

Предположим, что наша модификация требует, чтобы каждый «a» превратился в «b», а каждый «x» превратился в «y». Наш byteChangingReader позволяет нам изменять только один байт за раз, но мы можем решить эту проблему, передав один экземпляр в другой в качестве источника io.Reader. Код (с опущенной для краткости обработкой ошибок) будет выглядеть так:

Обратите внимание, что функция io.Copy копирует все доступные данные из реализации io.Reader в реализацию io.Writer. Приведенный выше пример, по общему признанию, несколько небольшой и надуманный, но можно построить целые конвейеры операций, реализовав эти небольшие интерфейсы. Например, в бэкэнде codebeat у нас есть механизм, который берет входной каталог, анализирует его, шифрует результат при вычислении хэша незашифрованного контента и выгружает зашифрованный результат по HTTP с хешем незашифрованного контента как часть ключа загрузки. . Мы построили весь конвейер на основе нескольких реализаций io.Reader и io.Writer, которые в основном происходят из стандартной библиотеки Go. Что в этом хорошего, так это то, что, поскольку io.Reader и io.Writer работают с фрагментированными данными, объем памяти, занимаемый всей операцией, равен размеру текущего фрагмента данных. обработанный.

Другой волшебный интерфейс, который я хотел представить, - это Monad Haskell. Окутанная тайной « монада - это моноид из категории эндофункторов . Для остальных из нас это ценность, заключенная в контекст. В Haskell монада - это класс типа, определенный следующим образом:

Фактически, есть еще две операции, которые реализует монада, но у них есть разумные реализации по умолчанию, поэтому они в основном не имеют отношения к программисту. Два метода, которые нам нужно реализовать, относительно просты - return (который, кстати, не имеет ничего общего с return в большинстве языков) принимает значение и помещает его в Монада. ›› = принимает значение, заключенное в монаду, и функцию, которая принимает значение без оболочки и возвращает - очевидно, - монаду.

Приведенный выше пример звучит несколько сложно, но на практике это просто способ объединить несколько операций, каждая из которых может потерпеть неудачу. Чтобы проиллюстрировать концепцию, давайте создадим нашу собственную монаду. В соответствии с нашей банковской темой пусть он представляет собой Аккаунт. Учетную запись можно либо закрыть, либо активировать с балансом, представленным произвольным значением:

Предположим, что нас интересуют только две операции - изменение баланса на заданное целое число и закрытие аккаунта. Первая операция действительна только тогда, когда учетная запись Активна. Запуск его в Отмененной учетной записи должен вернуть Отмененную учетную запись, точка. Однако изменение баланса - это всего лишь операция добавления, и мы не хотели бы заниматься обработкой отмененных учетных записей. Мы можем сделать Account экземпляром (реализацией) Monad, чтобы использовать обширную поддержку Haskell для этого класса сущностей:

Теперь давайте попробуем несколько примеров, чтобы увидеть, как все начинает хорошо становиться на свои места:

λ> Active 10 >>= addBalance 10 >>= addBalance 12
Active 32
λ> Active 10 >>= addBalance 12 >>= cancel >>= addBalance 12
Canceled

Итак, вот она, монада и безопасный способ управлять нашей модной банковской системой.

Вывод

В этой статье мы рассмотрели интерфейсы и их использование. Я пытался убедить вас, что использование интерфейсов приведет к лучшему дизайну системы, улучшенной ремонтопригодности и уменьшению сложности. Вдобавок я выступал за небольшие интерфейсы, показывая, чем они могут быть лучше больших. Мы закончили двумя волшебными примерами интерфейсов из объектно-ориентированного и функционального миров.

Вскоре codebeat начнет проверять размер интерфейса и будет отмечать большие интерфейсы как нарушение лучших практик. Сейчас мы пытаемся откалибровать, какие пороговые значения мы должны принять для каждого из поддерживаемых языков. Пожалуйста, сообщите нам свое мнение!

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