Вот в чем вопрос

Программисты никогда не могут договориться ни о чем, но, безусловно, один из самых больших аргументов, которые постоянно беспокоят интернет, — это битва между объектно-ориентированным программированием (ООП) и функциональным программированием (ФП).

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

Язык, обычно используемый для демонстрации ООП, — это Java. В Java все должно быть заключено в класс, включая основной цикл выполнения вашей программы.

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

Наиболее популярными языками функционального программирования являются Clojure, Elixir и Haskell.

Но как насчет Питона?

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

Правда в том, что Python очень гибкий. Если вы пришли с Java и хотите писать все в чисто объектно-ориентированном стиле, вы сможете выполнить большую часть того, что хотите. Если вы ранее работали с Clojure, у вас не возникнет особых проблем с воспроизведением шаблонов FP в Python.

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

Ниже приведены три примера одной и той же (очень простой) программы, написанной на ООП, ФП и более Pythonic-смесь того и другого. Я выделю сильные и слабые стороны каждого из них, что должно дать вам хорошую основу при разработке вашего следующего проекта Python.

Программа

Программа, использованная для демонстрации, очень проста — она создает двух животных (собаку и рыбу) и заставляет их выполнять очень простые действия. В этом примере действия просто регистрируются в stdout, но они, очевидно, могут делать намного больше.

Пример ООП

from abc import ABC, abstractmethod


class Logger(ABC):
    @abstractmethod
    def log(self, message: str):
        ...


class MyLogger(Logger):
    def __init__(self, name: str):
        self.name = name

    def log(self, message: str):
        print(f'{self.name}: {message}')


class Animal:
    def __init__(self, name: str, logger: Logger):
        self.name = name
        self.logger = logger

    def speak(self):
        self.logger.log('Speaking')
        ...


class Dog(Animal):
    def speak(self):
        self.logger.log('Woof!')
        ...

    def run(self):
        self.logger.log('Running')
        ...


class Fish(Animal):
    ...


class App:
    @staticmethod
    def run():
        fido = Dog(name='Fido', logger=MyLogger('Fido'))
        goldie = Fish(name='Goldie', logger=MyLogger('Goldie'))

        fido.speak()
        fido.run()

        goldie.speak()


if __name__ == '__main__':
    App.run()

# Fido: Woof!
# Fido: Running
# Goldie: Speaking

Как видите, код создает класс MyLogger для регистрации событий в stdout, базовый класс Animal, а затем классы Dog и Fish для более конкретных животных.

Чтобы лучше следовать парадигме ООП, в ней также определяется класс App с единственным методом run, который запускает программу.

Что хорошо в ООП и наследовании, так это то, что нам не нужно определять метод speak в классе Fish, и он по-прежнему сможет говорить.

Однако, если бы мы хотели иметь больше животных, которые могли бы бегать, нам пришлось бы ввести класс RunningAnimal между Animal и Dog, который определяет метод run, и, возможно, аналогичный класс SwimmingAnimal для Fish, но тогда наши иерархии начинают становиться все больше и больше. сложный.

Кроме того, классы MyLogger и App здесь практически бесполезны. Каждый делает только одну вещь и на самом деле делает код немного менее читаемым. Их лучше вытащить в функции log и main (или run).

Нам также пришлось создать абстрактный базовый класс Logger исключительно для того, чтобы в коде можно было правильно указать тип, и чтобы пользователи нашего API могли передавать другие средства ведения журнала, если они хотят войти в другое место, кроме stdout, или если они хотят войти в систему. с другим форматом.

Пример ПП

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

import functools
from typing import Callable

Logger = Callable[[str], None]


def log(message: str, name: str):
    print(f'{name}: {message}')


def bark(
    name: str,
    log_fn: Logger,
) -> (str, Logger):
    log_fn('Woof!')
    return name, log_fn


def run(
    name: str,
    log_fn: Logger,
) -> (str, Logger):
    log_fn('Running')
    return name, log_fn


def speak(
    name: str,
    log_fn: Logger,
) -> (str, Logger):
    log_fn('Speaking')
    return name, log_fn


def main():
    run(
        *bark(
            'Fido',
            functools.partial(log, name='Fido'),
        ),
    )

    speak(
        'Goldie',
        functools.partial(log, name='Goldie'),
    )


if __name__ == '__main__':
    main()

# Fido: Woof!
# Fido: Running
# Goldie: Speaking

Сразу видно, что наш класс Logger стал удобным псевдонимом типа для Callable[[str], None]. Мы также определили функцию log для обработки нашей печати. Вместо того, чтобы определять классы для наших животных, мы просто определили функции, которые принимают имя животного и Logger функцию.

Вы заметите, что функции run, speak и bark также возвращают свое имя и аргументы функции регистрации, что позволяет объединить их в конвейеры, как мы сделали для run и bark для Fido.

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

Чтобы обойти тот факт, что наша функция log не соответствует типу Logger, мы используем functools.partial для создания частичной функции, которая соответствует. Это позволяет нам заменить наш регистратор чем угодно, если мы можем использовать частичную функцию, чтобы уменьшить его, чтобы он соответствовал нашему типу Logger.

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

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

Немного того и другого

Итак, что произойдет, если мы объединим немного ООП с немного FP? Я собираюсь добавить еще несколько кусочков Pythonic, чтобы отойти от традиционных парадигм ООП и FP и, надеюсь, сделать код немного чище и легче для чтения.

from dataclasses import dataclass
from functools import partial
from typing import Callable

Logger = Callable[[str], None]


def log(message: str, name: str):
    print(f'{name}: {message}')


@dataclass
class Animal:
    name: str
    log: Logger

    def speak(self):
        self.log('Speaking')


@dataclass
class Dog(Animal):
    breed: str = 'Labrador'

    def speak(self):
        self.log('Woof!')

    def run(self):
        self.log('Running')


@dataclass
class Fish(Animal):
    ...


def main():
    fido = Dog('Fido', partial(log, name='Fido'))
    goldie = Fish('Goldie', partial(log, name='Goldie'))

    fido.speak()
    fido.run()

    goldie.speak()


if __name__ == '__main__':
    main()

# Fido: Woof!
# Fido: Running
# Goldie: Speaking

В этом примере я использую модуль Python dataclasses, чтобы не писать конструкторы для своих классов. Это не только сокращает часть кода, который мне нужно написать, но и значительно упрощает добавление новых атрибутов в конце строки, если мне это нужно.

Как и в примере с ООП, у нас есть базовый класс Animal с подклассами Dog и Fish. Однако, как и в примере с FP, я использую псевдоним типа Logger и functools.partial для создания регистраторов для животных. Это упрощается благодаря поддержке Python функций как граждан первого класса.

Кроме того, функция main — это просто функция. Я никогда не пойму, почему Java такая, какая она есть.

Смешивание ООП и ФП в продакшене

Итак, я признаю, что этот пример был невероятно простым, и хотя он послужил хорошей отправной точкой для этого обсуждения, теперь я хотел бы дать вам пример того, как эти концепции используются в производстве, и я собираюсь использовать две мои любимые библиотеки Python: FastAPI и Pydantic. FastAPI — это облегченная платформа API для Python, а Pydantic — это библиотека для проверки данных и управления настройками.

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

FastAPI позволяет вам определять ваши API-маршруты как функции, оборачивая каждый из них декоратором (что очень похоже на концепцию FP), чтобы инкапсулировать вашу логику.

Ниже приведен пример того, как это можно использовать. Опять же, это простой пример, но он достаточно репрезентативен для того, что вы можете увидеть в продакшене.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Baz(BaseModel):
    qux: int


class Foo(BaseModel):
    bar: str
    baz: Baz


@app.get('/foo')
async def get_foo(name: str, age: int) -> Foo:
    ...  # Some logic here
    return Foo(
        bar=name,
        baz=Baz(qux=age),
    )

# GET /foo?name=John&age=42
# {
#   "bar": "John",
#   "baz": {
#     "qux": 42
#   }
# }

Как видите, FastAPI использует способность Pydantic преобразовывать вложенные объекты в JSON для создания ответа JSON для нашей конечной точки. Декоратор app.get также зарегистрировал нашу функцию get_foo с объектом app, что позволяет нам делать GET запросов к конечной точке /foo.

Я надеюсь, что вы нашли эту статью полезной. Я хотел бы услышать, что вы думаете и к какой парадигме вы склоняетесь при написании Python.

Очевидно, что это не единственный способ объединить FP и ООП в Python, и существует множество шаблонов проектирования, которые можно реализовать и улучшить при использовании такого сочетания.

Я буду писать о них в будущем, и, подписавшись на меня в Медиуме, вы ничего не пропустите. Я также пишу о Python и моих текущих проектах в Твиттере и (совсем недавно) также публикую о них в Mastodon.

Я уверен, что скоро увидимся!

-Исаак