互斥关键字参数的优雅模式?

15

有时在我的代码中,我有一个函数可以通过两种方式之一接受参数。例如:

def func(objname=None, objtype=None):
    if objname is not None and objtype is not None:
        raise ValueError("only 1 of the ways at a time")
    if objname is not None:
        obj = getObjByName(objname)
    elif objtype is not None:
        obj = getObjByType(objtype)
    else:
        raise ValueError("not given any of the ways")

    doStuffWithObj(obj)

有没有更优雅的方法来做这件事?如果参数可以以三种方式之一出现怎么办?如果类型是不同的,我可以这样做:

def func(objnameOrType):
    if type(objnameOrType) is str:
        getObjByName(objnameOrType)
    elif type(objnameOrType) is type:
        getObjByType(objnameOrType)
    else:
        raise ValueError("unk arg type: %s" % type(objnameOrType))

但如果它们不是呢?这种替代方案似乎很愚蠢:

def func(objnameOrType, isName=True):
    if isName:
        getObjByName(objnameOrType)
    else:
        getObjByType(objnameOrType)

因为这样你必须像这样调用它:func(mytype, isName=False),这很奇怪。


8
为什么不只写两个不同的函数,一个接收名字,另一个接收类型?这种做法似乎只会让API的使用者感到困惑。 - Mark Rushakoff
3
或者,如果你真的想要一个能够完成所有任务的函数,为什么不接受一个参数,然后再找出这个参数的类型或名称呢?如果参数是一个类型,就按照类型查找对象;否则,根据名称查找对象。或者只需先尝试其中一种方法,然后再尝试另一种方法,看哪一种方法有效(我无法想象有哪种情况会同时检索到两个结果)。 - kindall
1
@Mark:如果该函数是一个构造函数,那么这样做行不通。 - Claudiu
8个回答

9
如何使用类似命令分发模式的东西:
def funct(objnameOrType):
   dispatcher = {str: getObjByName,
                 type1: getObjByType1,
                 type2: getObjByType2}
   t = type(objnameOrType)
   obj = dispatcher[t](objnameOrType)
   doStuffWithObj(obj)

其中type1type2等是实际的Python类型(例如int、float等)。


3

使其稍微缩短的一种方法是

def func(objname=None, objtype=None):
    if [objname, objtype].count(None) != 1:
        raise TypeError("Exactly 1 of the ways must be used.")
    if objname is not None:
        obj = getObjByName(objname)
    else: 
        obj = getObjByType(objtype)

我还没有决定是否称之为“优雅”。

请注意,如果给出了错误数量的参数,应该引发一个 TypeError 而不是 ValueError


为什么会出现TypeError错误?是因为其中一个参数的类型必须是NoneType吗? - Ryan C. Thompson
1
@Ryan:为了与 Python 中 ValueErrorTypeError 的一般用法保持一致。如果您向函数传递错误数量的参数,Python 将引发 TypeError,因此您应该这样做。 - Sven Marnach
@Sven,你觉得if bool(objname) == bool(objtype):怎么样? - senderle
1
@senderle:如果您传递了假值,它会执行不同的操作。 - Sven Marnach
@Sven,没错,但我们想要拒绝像''和0这样的假值,对吧?不过我想我明白你的意思了——上面的代码消除了传入值和默认值之间的区别... - senderle
@Sven,不确定为什么我变得着迷于这个问题,但是这是另一次尝试:if (objename == None) == (objtype == None):。当然,没有必要采用它,但你认为这样做可以接受吗? - senderle

3
听起来应该去https://codereview.stackexchange.com/

无论如何,保持相同的接口,我可以尝试。

arg_parsers = {
  'objname': getObjByName,
  'objtype': getObjByType,
  ...
}
def func(**kwargs):
  assert len(kwargs) == 1 # replace this with your favorite exception
  (argtypename, argval) = next(kwargs.items())
  obj = arg_parsers[argtypename](argval) 
  doStuffWithObj(obj)

还是创建两个函数比较好呢?
def funcByName(name): ...
def funcByType(type_): ...

+1 给 codereview,我甚至不知道它存在!但 next 函数是什么? - Claudiu
@Claudiu:next 从迭代器中返回一个项目并推进迭代器。在这种用法中,它大致相当于 x[0] - kennytm
啊好的,我在Python里输入了它,但是说它没有定义,显然这个函数是新的,在2.6版中才加入的。 - Claudiu
1
你应该使用assert来捕获自己的编码错误,而不是调用者错误的参数。 - endolith

3

无论价值如何,标准库中也会发生类似的事情;例如,在gzip.py中的GzipFile开头就有类似情况(此处已删除文档字符串):

class GzipFile:
    myfileobj = None
    max_read_chunk = 10 * 1024 * 1024   # 10Mb
    def __init__(self, filename=None, mode=None,
                 compresslevel=9, fileobj=None):
        if mode and 'b' not in mode:
            mode += 'b'
        if fileobj is None:
            fileobj = self.myfileobj = __builtin__.open(filename, mode or 'rb')
        if filename is None:
            if hasattr(fileobj, 'name'): filename = fileobj.name
            else: filename = ''
        if mode is None:
            if hasattr(fileobj, 'mode'): mode = fileobj.mode
            else: mode = 'rb'

当然,这个函数接受filenamefileobj两个关键字参数,并在同时接收到这两个参数时定义了特定的行为;但总体的方法似乎基本相同。

2

我使用了一个装饰器:

from functools import wraps

def one_of(kwarg_names):
    # assert that one and only one of the given kwarg names are passed to the decorated function
    def inner(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            count = 0
            for kw in kwargs:
                if kw in kwarg_names and kwargs[kw] is not None:
                    count += 1

            assert count == 1, f'exactly one of {kwarg_names} required, got {kwargs}'

            return f(*args, **kwargs)
        return wrapped
    return inner

用途:

@one_of(['kwarg1', 'kwarg2'])
def my_func(kwarg1='default', kwarg2='default'):
    pass

请注意,这仅适用于作为关键字参数传递的非 None 值。例如,如果除一个之外的所有 kwarg_names 的值都为 None,则仍然可以传递其中多个。
为了允许不传递关键字参数,请断言计数小于或等于 1。

0

听起来你正在寻找函数重载,但这在Python 2中并没有实现。在Python 2中,你的解决方案已经是最好的了。

你可以通过允许你的函数处理多个对象并返回生成器来绕过额外参数问题:

import types

all_types = set([getattr(types, t) for t in dir(types) if t.endswith('Type')])

def func(*args):
    for arg in args:
        if arg in all_types:
            yield getObjByType(arg)
        else:
            yield getObjByName(arg)

测试:

>>> getObjByName = lambda a: {'Name': a}
>>> getObjByType = lambda a: {'Type': a}
>>> list(func('IntType'))
[{'Name': 'IntType'}]
>>> list(func(types.IntType))
[{'Type': <type 'int'>}]

0

我偶尔也会遇到这个问题,很难找到一个容易推广的解决方案。比如说,我有更复杂的参数组合,由一组互斥的参数界定,并且想要为每个参数组合支持额外的参数(其中一些可能是必需的,一些是可选的),就像以下签名:

def func(mutex1: str, arg1: bool): ...
def func(mutex2: str): ...
def func(mutex3: int, arg1: Optional[bool] = None): ...

我会使用面向对象的方法,将参数封装在一组描述符中(名称取决于参数的业务含义),然后可以通过pydantic之类的工具进行验证:

from typing import Optional
from pydantic import BaseModel, Extra

# Extra.forbid ensures validation error if superfluous arguments are provided
class BaseDescription(BaseModel, extra=Extra.forbid):
    pass  # Arguments common to all descriptions go here

class Description1(BaseDescription):
    mutex1: str
    arg1: bool

class Description2(BaseDescription):
    mutex2: str

class Description3(BaseDescription):
    mutex3: int
    arg1: Optional[bool]

你可以使用工厂实例化这些描述:
class DescriptionFactory:
    _class_map = {
        'mutex1': Description1,
        'mutex2': Description2,
        'mutex3': Description3
    }
    
    @classmethod
    def from_kwargs(cls, **kwargs) -> BaseDescription:
        kwargs = {k: v for k, v in kwargs.items() if v is not None}
        set_fields = kwargs.keys() & cls._class_map.keys()
        
        try:
            [set_field] = set_fields
        except ValueError:
            raise ValueError(f"exactly one of {list(cls._class_map.keys())} must be provided")
        
        return cls._class_map[set_field](**kwargs)
    
    @classmethod
    def validate_kwargs(cls, func):
        def wrapped(**kwargs):
            return func(cls.from_kwargs(**kwargs))
        return wrapped

然后,您可以像这样包装实际的函数实现,并使用类型检查来查看提供了哪些参数:

@DescriptionFactory.validate_kwargs
def func(desc: BaseDescription):
    if isinstance(desc, Description1):
        ...  # use desc.mutex1 and desc.arg1
    elif isinstance(desc, Description2):
        ...  # use desc.mutex2
    ...  # etc.

并且可以调用为func(mutex1='', arg1=True)func(mutex2='')func(mutex3=123)等等。

这不是总体上更短的代码,但它以非常描述性的方式执行参数验证,根据您的规范引发有用的pydantic错误,并在函数实现的每个分支中产生准确的静态类型。

请注意,如果您使用的是Python 3.10+,结构模式匹配可以简化其中的某些部分。


0
内置的sum()函数可用于对布尔表达式列表进行操作。在Python中,boolint的子类,在算术运算中,True作为1,False作为0。
这意味着这段相当简短的代码将测试任意数量参数的互斥性:
def do_something(a=None, b=None, c=None):
    if sum([a is not None, b is not None, c is not None]) != 1:
        raise TypeError("specify exactly one of 'a', 'b', or 'c'")

还有其他可能的变化:

def do_something(a=None, b=None, c=None):
    if sum([a is not None, b is not None, c is not None]) > 1:
        raise TypeError("specify at most one of 'a', 'b', or 'c'")

或者是 if sum([bool(a), bool(b), bool(c)]) > 1 - mhucka
我认为这会让它看起来更“聪明”(我不喜欢聪明),但重要的是,它不会以相同的方式行事,因为bool转换是一个“falsy”/“truthy”检查,而这里的目标是检查缺失值。例如,空列表/字符串/集合值将表现不同。(通常,缺失值由None表示,但如果None具有特殊含义,则可以使用另一个特殊的sentinal值,例如MISSING = object()。在这种情况下,“is not None”检查将变为“is not MISSING”。) - wouter bolsterlee

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接