在 Python 中的 "template method pattern" 继承中,对 Derived class 上从 Base class 继承的方法使用已实现方法的签名

Using signature of implemented method on Derived class for inherited method from Base class in a "template method pattern" inheritance in Python

考虑我们有 Template method pattern 继承场景。我正在使用带有请求库的示例。

import abc
import json
import requests
from typing import Dict, Any

class Base(abc.ABC)
    @abc.abstractmethod
    def call(self, *args, **kwargs) -> requests.Response:
        raise NotImplementedError

    def safe_call(self, *args, **kwargs) -> Dict[str, Any]:
        try:
            response = self.call(*args, **kwargs)
            response.raise_for_status()
            return response.json()
        except json.JSONDecodeError as je: ...
        except requests.exceptions.ConnectionError as ce: ...
        except requests.exceptions.Timeout as te: ...
        except requests.exceptions.HTTPError as he: ...
        except Exception as e: ...
        return {"success": False}


class Derived(Base):
    def call(self, url: str, json: Dict[str, Any], timeout: int, retries: int) -> requests.Response:
        # actual logic for making the http call
        response = requests.post(url, json=json, timeout=timeout, ...)
        return response

将像

一样使用
executor = Derived()
response = executor.safe_call(url='https://example.com', json={"key": "value"}, ...)

有没有办法将 Derived.call 的签名附加到 Derived.safe_call 以便现代 IDE 自动完成和类型检查器工作? 因为这看起来非常像装饰器模式,所以我尝试在 __init__ 中使用 functools.update_wrapper 但失败了

AttributeError: 'method' object has no attribute '__module__'

我确实有几个选择,但我找不到关于这个问题的任何建议

  1. 在 class 完全构建之前,我可以使用 metaclass 路由来更改签名和文档字符串属性。但是,这可能不适用于 IDEs.
  2. 我可以定义一个存根文件 .pyi 并将其与主要代码一起维护。
class Derived(Base):
    def safe_call(self, url: str, json: Dict[str, Any], timeout: int, retries: int) -> Dict[str, Any]: ...
  1. 完全改变设计

  2. (编辑:添加 post 第一个答案)(Ab)使用 @typing.overload

class Derived(Base):
    @typing.overload
    def safe_call(self, url: str, json: Dict[str, Any], timeout: int, retries: int) -> Dict[str, Any]:
        ...

    def call(self, url: str, json: Dict[str, Any], timeout: int, retries: int) -> requests.Response:
        # actual logic for making the http call
        response = requests.post(url, json=json, timeout=timeout, ...)
        return response

我明白你想做什么,但我认为走那条路可能是个错误,因为它会破坏 Liskov Substitution Principle

目前,您的抽象 class Base 定义了一个接口,其中方法 Base.call 可以在接口的具体实现中以 任何位置调用或关键字参数 而不会在运行时引发错误。从理论上讲,你的Derivedclass中call的具体实现不满足这个接口。如果使用 >4 个参数调用、使用 <4 个参数调用或使用非 urljsontimeoutretries 的关键字参数调用,它将在运行时引发错误。

Python 仍然会让你在运行时 实例化 Derived 的实例,因为它只检查是否存在与Base 中定义的抽象方法。它不会在具体实现实例化时检查抽象方法的签名兼容性,并且尝试这样做将非常困难。但是,某些类型检查器可能会抱怨派生 class 中的签名不如基础 class 中的签名宽松。此外,理论原则本身也很重要。

我认为要走的路可能是这样的。此处注意两点:

  1. 基础 class 中的 safe_callcall 方法都变成了私有方法,因为它们是实现细节,不能直接使用。
  2. 我更改了 callsafe_call 基 class 中的签名,以便它们只接受关键字参数。
import abc
import json
import requests
from typing import Dict, Any

class Base(abc.ABC)
    @abc.abstractmethod
    def _call(self, **kwargs: Any) -> requests.Response:
        raise NotImplementedError

    def _safe_call(self, **kwargs: Any) -> Dict[str, Any]:
        try:
            response = self.call(**kwargs)
            response.raise_for_status()
            return response.json()
        except json.JSONDecodeError as je: ...
        except requests.exceptions.ConnectionError as ce: ...
        except requests.exceptions.Timeout as te: ...
        except requests.exceptions.HTTPError as he: ...
        except Exception as e: ...
        return {"success": False}


class Derived(Base):
    def _call(self, **kwargs: Any) -> requests.Response:
        url: str = kwargs['url']
        json: Dict[str, Any] = kwargs['json']
        timeout: int = kwargs['timeout']
        retries: int = kwargs['retries']
        
        # actual logic for making the http call
        response = requests.post(url, json=json, timeout=timeout, ...)
        return response

    def safe_call_specific_to_derived(self, url: str, json: Dict[str, Any], timeout: int, retries: int) -> Dict[str, Any]:
        return self._safe_call(url=url, json=json, timeout=timeout, retries=retries)

如果您想在 _call 的具体实现中使用默认参数值,请注意您可以使用 kwargs.get(<arg_name>, <default_val>) 而不是 kwargs[<arg_name>]