避免 Python 中的模块命名空间污染



TL;DR:将实现细节排除在模块命名空间之外的最干净方法是什么?

关于这个主题已经有许多类似的问题,但相对于现代工具和语言功能,似乎没有一个得到令人满意的答案。

我正在设计一个 Python 包,我想保持每个模块的公共接口干净,只公开预期的内容,隐藏实现细节(尤其是导入(。

多年来,我看到了许多技术:

别担心。 只需记录如何使用您的包,并让它的使用者忽略实现细节。

在我看来,这太可怕了。 设计良好的界面应该易于发现。 公开实现细节会使界面更加混乱。 即使作为包的作者,我也不想在它公开太多时使用它,因为它使自动完成变得不那么有用。

在所有实现详细信息的开头添加下划线。

这是一个很好理解的约定,大多数开发工具都足够聪明,至少可以将下划线前缀的名称排序到自动完成列表的底部。 如果您有少量名称以这种方式处理,它工作正常,但随着名称数量的增加,它变得越来越乏味和丑陋。

以这个相对简单的导入列表为例:

import struct
from abc    import abstractmethod, ABC
from enum   import Enum
from typing import BinaryIO, Dict, Iterator, List, Optional, Type, Union

应用下划线技术,这个相对较小的导入列表变成了这个怪物:

import struct as _struct
from abc    import abstractmethod as _abstractmethod, ABC as _ABC
from enum   import Enum as _Enum
from typing import (
BinaryIO as _BinaryIO,
Dict     as _Dict,
Iterator as _Iterator,
List     as _List,
Optional as _Optional,
Type     as _Type,
Union    as _Union
)

现在,我知道这个问题可以通过从不执行from导入,只导入整个包,并对所有包进行限定来部分缓解。 虽然这确实有助于这种情况,而且我意识到有些人无论如何都喜欢这样做,但这并不能消除问题,也不是我的偏好。 有些包我更喜欢直接导入,但我通常更喜欢显式导入类型名称和装饰器,以便我可以非限定地使用它们。

下划线前缀还有一个小问题。 参加以下公开曝光的课程:

class Widget(_ABC):
@_abstractmethod
def implement_me(self, input: _List[int]) -> _Dict[str, object]:
...

实现自己的Widget实现的这个包的使用者将看到他需要实现implement_me方法,并且需要_List并返回_Dict。 这些不是实际的类型名称,现在实现隐藏机制已泄漏到我的公共接口中。 这不是一个大问题,但它确实导致了这个解决方案的丑陋。

隐藏函数内的实现详细信息。

这个绝对是黑客,它不能很好地与大多数开发工具配合使用。

下面是一个示例:

def module():
import struct
from abc    import abstractmethod, ABC
from typing import BinaryIO, Dict, List
def fill_list(r: BinaryIO, count: int, lst: List[int]) -> None:
while count > 16:
lst.extend(struct.unpack("<16i", r.read(16 * 4)))
count -= 16
while count > 4:
lst.extend(struct.unpack("<4i", r.read(4 * 4)))
count -= 4
for _ in range(count):
lst.append(struct.unpack("<i", r.read(4))[0])
def parse_ints(r: BinaryIO) -> List[int]:
count = struct.unpack("<i", r.read(4))[0]
rtn: List[int] = []
fill_list(r, count, rtn)
return rtn
class Widget(ABC):
@abstractmethod
def implement_me(self, input: List[int]) -> Dict[str, object]:
...
return (parse_ints, Widget)
parse_ints, Widget = module()
del module

这有效,但它超级黑客,我不希望它在所有开发环境中都能干净地运行。 例如,ptpython无法为parse_ints函数提供方法签名信息。 此外,Widget的类型变得my_package.module.<locals>.Widget而不是my_package.Widget,这对消费者来说是奇怪和困惑的。

使用__all__.

这是此问题的常用解决方案:在全局__all__变量中列出"public"成员:

import struct
from abc    import abstractmethod, ABC
from typing import BinaryIO, Dict, List
__all__ = ["parse_ints", "Widget"]
def fill_list(r: BinaryIO, count: int, lst: List[int]) -> None:
...  # You've seen this.
def parse_ints(r: BinaryIO) -> List[int]:
...  # This, too.
class Widget(ABC):
...  # And this.

这看起来不错而且很干净,但不幸的是,__all__唯一受影响的是当您使用通配符导入时会发生什么from my_package import *,无论如何,大多数人都不会这样做。

将模块转换为子包,并在__init__.py中公开公共接口。

这就是我目前正在做的事情,在大多数情况下它很干净,但是如果我公开多个模块而不是扁平化所有模块,它可能会变得丑陋:

my_package/
+--__init__.py
+--_widget.py
+--shapes/
+--__init__.py
+--circle/
|  +--__init__.py
|  +--_circle.py
+--square/
|  +--__init__.py
|  +--_square.py
+--triangle/
+--__init__.py
+--_triangle.py

然后我的__init__.py文件看起来像这样:

# my_package.__init__.py
from my_package._widget.py import parse_ints, Widget
# my_package.shapes.circle.__init__.py
from my_package.shapes.circle._circle.py import Circle, Sphere
# my_package.shapes.square.__init__.py
from my_package.shapes.square._square.py import Square, Cube
# my_package.shapes.triangle.__init__.py
from my_package.shapes.triangle._triangle.py import Triangle, Pyramid

这使我的界面干净,并且可以很好地与开发工具配合使用,但是如果我的包不是完全扁平的,它会使我的目录结构非常混乱。

有没有更好的技术?

转换为子包以限制一个位置中的类数并分离关注点。 如果在其模块之外不需要类或常量,请在其前面加上双下划线。如果不想从模块名称中显式导入许多类,请导入模块名称。 您已经制定了所有解决方案。

不确定这是否会破坏任何东西,但可以做

"""Module Docstring"""
__all__ = [
# Classes
"Foo",
# Functions
"bar",
]
__ALL__ = dir() + __all__  # catch default module attributes.
# Imports go here
def __dir__() -> list[str]:
return __ALL__

说明:dir(obj)尝试调用obj.__dir__()。模块也是对象,我们可以添加一个自定义__dir__方法。使用此设置,您应该得到

dir(module) = ['__all__', '__builtins__', '__cached__',
'__doc__', '__file__', '__name__', 
'__package__', '__spec__', 'Foo', 'bar']

编号:PEP 562

最新更新