Использование интерфейсов и протоколов

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

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

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

Куда мы можем сохранить эту информацию? В плоский txt-файл. В файл JSON. В базу данных SQL. В NoSQL базу данных. Отправить куда-то по сети в какой-то веб-сервис. Вариантов много и потенциально в будущем возможно нам захочется заменить текущий выбранный вариант на какой-то другой. Давайте реализуем модуль history.py, который будет отвечать за сохранение истории:

from weather_api_service import Weather

class WeatherStorage:
    """Interface for any storage saving weather"""
    def save(self, weather: Weather) -> None:
        raise NotImplementedError

def save_weather(weather: Weather, storage: WeatherStorage) -> None:
    """Saves weather in the storage"""
    storage.save(weather)

Здесь WeatherStorage — это интерфейс в терминах объектно-ориентированного программирования. Этот интерфейс описывает те методы, которые обязательно должны присутствовать у любого хранилища погоды. Собственно говоря, у любого хранилища погоды должен быть как минимум метод save, который принимает на вход погоду, которую он должен сохранить.

В интерфейсе WeatherStorage нет реализации (на то он и интерфейс), он только объявляет метод save, который должен быть определён в любом классе, реализующем этот интерфейс.

Функция save_weather будет вызываться более высокоуровневым управляющим кодом для сохранения погоды в хранилище. Эта функция принимает на вход погоду weather, которую надо сохранить, и реальный экземпляр хранилища storage, которое реализует интерфейс WeatherStorage.

Чтобы показать, что метод save интерфейса не реализован, мы возбуждаем в нём исключение NotImplementedError, эта ошибка говорит о том, что вызываемый метод не реализован. Таким образом, если мы создадим хранилище, отнаследованное от этого интерфейса, не реализуем в нём метод save и вызовем его, то у нас упадёт в рантайме исключение NotImplementedError:

class PlainFileWeatherStorage(WeatherStorage):
    pass

storage = PlainFileWeatherStorage()
storage.save()  # Тут в runtime упадёт ошибка NotImplementedError

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

Какой есть ещё вариант определения интерфейсов в Python? Есть вариант с использованием встроенного модуля ABC (документация), созданного как раз для работы с такими абстрактными классами и интерфейсами:

from abc import ABC, abstractmethod

class WeatherStorage(ABC):
    """Interface for any storage saving weather"""
    @abstractmethod
    def save(self, weather: Weather) -> None:
        pass

Экземпляр класса, наследующего таким образом объявленный интерфейс, не получится создать без явной реализации всех методов, объявленных с декоратором @abstractmethod. То есть вот такой код в runtime упадёт сразу в момент создания экземпляра такого класса:

class PlainFileWeatherStorage(WeatherStorage):
    pass

# Тут упадет ошибка в рантайме, так как в PlainFileWeatherStorage
# не определен метод save 
storage = PlainFileWeatherStorage()  

Опять же — код падает в runtime, пользователи видят ошибку, плохо. Как перенести проверку на корректность использования интерфейсов и абстрактных классов на IDE и статический анализатор кода?

Способ появился в Python 3.8 благодаря PEP 544, и он называется протоколами, Protocol:

from typing import protocol

class WeatherStorage(Protocol):
    """Interface for any storage saving weather"""
    def save(self, weather: Weather) -> None:
        pass

class PlainFileWeatherStorage:
    def save(self, weather: Weather) -> None:
        print("реализация сохранения погоды...")

def save_weather(weather: Weather, storage: WeatherStorage) -> None:
    """Saves weather in the storage"""
    storage.save(weather)

Воу! Класс PlainFileWeatherStorage никак не связан с WeatherStorage, не отнаследован от него, хотя и реализует его интерфейс в неявном виде, то есть просто определяет все функции, которые должны быть реализованы в этом интерфейсе. Сам интерфейс WeatherStorage отнаследован от класса typing.Protocol, что делает его так называемым протоколом. В функции save_weather тип аргумента storage по-прежнему установлен в этот интерфейс WeatherStorage.

Получается, что класс PlainFileWeatherStorage неявно реализует протокол/интерфейс WeatherStorage. Если вы работали с языком программирования Go — в нём интерфейсы реализованы схожим образом, это так называемая структурная типизация.

Почему использование такого подхода в приоритете? Потому что проверкой корректности использования интерфейсов занимается IDE и статический анализатор кода вроде mypy. Речь идёт уже не о проверке в runtime, речь идет о проверке корректности реализации до этапа, в котором участвуют пользователи программы. Это то, что нам нужно!

Таким образом, наш модуль history.py принимает следующий вид:

from datetime import datetime
from pathlib import Path
from typine import Protocol

from weather_api_service import Weather
from weather_formatter import format_weather

class WeatherStorage(Protocol):
    """Interface for any storage saving weather"""
    def save(self, weather: Weather) -> None:
        raise NotImplementedError

class PlainFileWeatherStorage:
    """Store weather in plain text file"""
    def __init__(self, file: Path):
        self._file = file

    def save(self, weather: Weather) -> None:
        now = datetime.now()
        formatted_weather = format_weather(weather)
        with open(self._file, "a") as f:
            f.write(f"{now}\n{formatted_weather}\n")

def save_weather(weather: Weather, storage: WeatherStorage) -> None:
    """Saves weather in the storage"""
    storage.save(weather)

PlainFileWeatherStorage это реализованное хранилище, отнаследованное от нашего интерфейса, то есть реализующее его методы. Помимо метода save этот класс реализует ещё конструктор, который сохраняет в поле self._file путь до файла, в который будет записываться информация о погоде.

Для перевода объекта погоды типа Weather в строку используется функция format_weather, которую мы реализовали ранее в модуле weather_formatter.

Этот код — абсолютно валиден с точки зрения проверки системы типов.

Вызовем теперь логику сохранения погоды в главном файле weather:

#!/usr/bin/env python3.10
from pathlib import Path

from exceptions import ApiServiceError, CantGetCoordinates
from coordinates import get_gps_coordinates
from history import PlainFileWeatherStorage, save_weather
from weather_api_service import get_weather
from weather_formatter import format_weather


def main():
    try:
        coordinates = get_gps_coordinates()
    except CantGetCoordinates:
        print("Не смог получить GPS-координаты")
        exit(1)
    try:
        weather = get_weather(coordinates)
    except ApiServiceError:
        print("Не смог получить погоду в API-сервиса погоды")
        exit(1)
    save_weather(
        weather,
        PlainFileWeatherStorage(Path.cwd() / "history.txt")
    )
    print(format_weather(weather))


if __name__ == "__main__":
    main()

Здесь мы создаём экземпляр объекта PlainFileWeatherStorage и передаём его на вход функции save_weather. Всё работает!

Для вывода содержимого текстового файла на скриншоте вместо cat использовался batпродвинутый вариант:)

Теперь, если мы захотим изменить хранилище, мы можем создать новое хранилище, например, JSON-хранилище, реализовав в нём все методы интерфейса WeatherStorage, и передать это новое хранилище в save_weather. Всё продолжит работать и будет корректно с точки зрения типов. Причём нам не придётся ничего менять в функции save_weather, так как она опирается только на интерфейс, определённый в классе WeatherStorage.

history.py, добавленный код:

import json
from typing import Protocol, TypedDict


class HistoryRecord(TypedDict):
    date: str
    weather: str

class JSONFileWeatherStorage:
    """Store weather in JSON file"""
    def __init__(self, jsonfile: Path):
        self._jsonfile = jsonfile
        self._init_storage()

    def save(self, weather: Weather) -> None:
        history = self._read_history()
        history.append({
            "date": str(datetime.now()),
            "weather": format_weather(weather)
        })
        self._write(history)
    
    def _init_storage(self) -> None:
        if not self._jsonfile.exists():
            self._jsonfile.write_text("[]")

    def _read_history(self) -> list[HistoryRecord]:
        with open(self._jsonfile, "r") as f:
            return json.load(f)

    def _write(self, history: list[HistoryRecord]) -> None:
        with open(self._jsonfile, "w") as f:
            json.dump(history, f, ensure_ascii=False, indent=4)

Здесь мы воспользовались структурой TypedDict, типизированным словарём. Это удобно для нашего сценария, так как каждая запись погоды в JSON-файл будет представлять собой как раз структуру словаря, состоящую из двух полей — date для даты и времени получения погоды и weather для описания погоды. Метод _read_history предназначен для чтения данных погоды из JSON-файла и он возвращает не list[dict], а list[HistoryRecord], максимально конкретный тип данных. Аналогично метод _write принимает в качестве аргумента не list[dict], а тоже list[HistoryRecord]. Везде используем максимально точную конкретную структуру данных.

weather, изменённый код:

from history import JSONFileWeatherStorage, save_weather


def main():
    # пропущено....
    save_weather(
        weather,
        JSONFileWeatherStorage(Path.cwd() / "history.json")
    )
    print(format_weather(weather))


if __name__ == "__main__":
    main()

Всё работает:

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