最近在做一些代码重构,涉及到Python中部分代码重构后,单元测试实现较为麻烦甚至难以实现的场景,其中一个主要的原因是构造函数过于复杂。
因此,本篇文章借此总结一下我们应该需要什么样的构造函数。本篇文章涉及的概念不仅限于Python。
构造函数是什么
构造函数用于创建对象时触发,如果不自定义构造函数,通常现代的编程语言在编译时会自动加一个无参的构造函数,同时将类成员设置成默认值,Python中需要定义对象成员才能访问,而类C语言比如C#中int、bool、float等都会设置为0等值的值,比如整数为0、浮点数为0.0或布尔值为false,对于非原始值的引用类型,比如String或Class,都会设置为Null。
构造函数是对类初始化非常合理的位置。因为构造函数是新建对象时触发,相比对象构造之后再去修改对象属性,带来的麻烦远比收益多,比如说空指针、时序耦合、多线程问题等,这些有兴趣后续有机会再聊,但总之将类的初始化放到构造函数中就像是先打地基再盖房子,而不是房子盖一半再回头修补地基,也避免类处于"半成品"状态。
虽然构造函数应该做完整的工作避免半成品,但如果给构造函数赋予太多的责任,会对系统带来很多麻烦,就好比房子主体结构(构造函数)还没完工,就要搬家具进房屋,通常会带来不必要的负担。
我们需要什么样的构造函数
一句话总结:在我看来,构造函数只应该做赋值,以及最基本的参数校验。而不应该做外部调用和复杂的初始化,使用简单构造函数能够带来如下好处:
可维护性
单一职责,避免惊喜
构造函数也应当遵循单一职责原则,仅负责对象的初始化和基本验证,而不应包含其他复杂操作。当构造函数承担过多责任时,会产生意外的"惊喜",使代码难以理解和维护。
例如下面代码,在构造函数中执行了数据库查询操作(外部依赖),以及统计计算(无外部依赖,复杂的内部计算),我们很难一眼看出该函数初始化要做什么,增加阅读和理解代码的认知负担。
class UserReport:
def __init__(self, user_id):
self.user_id = user_id
# 构造函数中进行数据库操作(有外部依赖)
self.user = database.fetch_user(user_id)
# 构造函数中执行复杂计算(内部复杂计算,无外部依赖)
self.statistics = self._calculate_statistics()
def _calculate_statistics(self):
# 假设是一个复杂的统计计算
return {"login_count": 42, "active_days": 15}
而理想的构造函数,应该只是简单做"初始化赋值"这一个操作,如下所示:
class UserReport:
def __init__(self, user, statistics):
"""构造函数只负责初始化,不执行其他操作"""
self.user = user
self.statistics = statistics
该构造函数只做初始化赋值,没有预期之外的情况,比如例子中_calculate_statistics函数,如果在方法内继续引用其他类,其他类再次有外部依赖的访问(比如IO、API调用、数据库操作等),会产生惊喜。
减少意外的副作用
构造函数中包含复杂操作不仅违反单一职责原则,还可能带来意外的副作用。这些副作用可能导致系统行为不可预测,增加调试难度,甚至引发难以察觉的bug。
我们继续看之前的代码示例:
class UserReport:
def __init__(self, user_id):
self.user_id = user_id
# 构造函数中进行数据库操作
self.user = database.fetch_user(user_id)
# 构造函数中执行复杂计算
self.statistics = self._calculate_statistics()
def _calculate_statistics(self):
# 复杂的统计计算
data = database.fetch_user_activities(self.user_id)
if not data:
# 可能抛出异常
raise ValueError(f"No activity data for user {self.user_id}")
return {"login_count": len(data), "active_days": len(set(d.date() for d in data))}
这段代码可以看到,_calculate_statistics() 函数有数据库访问,这是隐藏的依赖,同时如果数据库访问存在异常可能导致整个对象创建失败,调用者只想创建对象,却可能引发了数据库无法连接的异常。这在运行时都属于意外。
Traceback (most recent call last):
File "main.py", line 42, in <module>
report = UserReport(user_id=1001) # 调用者只是想创建一个报告对象
File "user_report.py", line 5, in __init__
self.user = database.fetch_user(user_id) # 数据库查询可能失败
File "database.py", line 78, in fetch_user
user_data = self._execute_query(f"SELECT * FROM users WHERE id = {user_id}")
File "database.py", line 31, in _execute_query
connection = self._get_connection()
File "database.py", line 15, in _get_connection
return pymysql.connect(host=self.host, user=self.user, password=self.password, db=self.db_name)
File "/usr/local/lib/python3.8/site-packages/pymysql/__init__.py", line 94, in Connect
return Connection(*args, **kwargs)
File "/usr/local/lib/python3.8/site-packages/pymysql/connections.py", line 327, in __init__
self.connect()
File "/usr/local/lib/python3.8/site-packages/pymysql/connections.py", line 629, in connect
raise exc
pymysql.err.OperationalError: (2003, "Can't connect to MySQL server on 'db.example.com' (timed out)")
而将计算逻辑提取到专门函数,访问外部依赖的逻辑通过注入进行,就不会存在该问题:
class UserReport:
def __init__(self, user, statistics=None):
"""构造函数只负责初始化,无副作用"""
self.user = user
self.statistics = statistics if statistics is not None else {}
def calculate_statistics(self, activity_source):
"""将计算逻辑分离到专门的方法,并接受依赖注入"""
activities = activity_source.get_activities(self.user.id)
self.statistics = {
"login_count": len(activities),
"active_days": len(set(a.date for a in activities))
}
return self.statistics
class UserActivity:
def __init__(self, user_id, date, action):
self.user_id = user_id
self.date = date
self.action = action
class DatabaseActivity:
def get_activities(self, user_id):
# 实际应用中会查询数据库
return database.fetch_user_activities(user_id)
方便调试和演进
构造函数仅负责简单的初始化时,代码变得更加易于调试和演进。相比之下,包含复杂逻辑的构造函数会使问题定位和系统扩展变得困难。比如下面例子
class UserReport:
def __init__(self, user_id):
self.user_id = user_id
self.user = database.fetch_user(user_id)
self.activities = database.fetch_user_activities(user_id)
self.statistics = self._calculate_statistics()
self.recommendations = self._generate_recommendations()
# 更多复杂逻辑...
可以看到构造函数包括了太多可能失败的点,调试时也不容易找到具体哪一行除了问题。而下面方式调试容易很多:
class UserReport:
def __init__(self, user, activities=None, statistics=None, recommendations=None):
self.user = user
self.activities = activities or []
self.statistics = statistics or {}
self.recommendations = recommendations or []
而演进时,复杂的构造函数有很大风险,例如:
# 需要修改原有构造函数,风险很高
class UserReport:
def __init__(self, user_id, month=None): # 添加新参数
self.user_id = user_id
self.user = database.fetch_user(user_id)
# 修改现有逻辑
if month:
self.activities = database.fetch_user_activities_by_month(user_id, month)
else:
self.activities = database.fetch_user_activities(user_id)
# 以下计算可能需要调整
self.statistics = self._calculate_statistics()
self.recommendations = self._generate_recommendations()
我们需要添加按月筛选活动数据,增加一个参数,这种情况也是实际代码维护中经常出现的,想到哪写到哪,导致构造函数变的非常复杂难以理解,同时增加出错可能性,而更好的方式如下:
class UserReport:
def __init__(self, user, activities=None, statistics=None, recommendations=None):
self.user = user
self.activities = activities or []
self.statistics = statistics or {}
self.recommendations = recommendations or []
def filter_by_month(self, month):
"""添加新功能作为单独的方法"""
filtered_activities = [a for a in self.activities if a.date.month == month]
return UserReport(
self.user,
activities=filtered_activities,
# 可根据需要重新计算或保留原有数据
)
新功能可以独立添加,不影响现有功能,同时也避免修改这种核心逻辑时测试不全面带来的上线提心吊胆。
可测试性
良好的构造函数设计对代码的可测试性有着决定性的影响。当构造函数简单且只负责基本初始化时,测试变得更加容易、更加可靠,且不依赖于特定环境。这也是为什么我写本篇文章的原因,就是在写单元测试时发现很多类几乎不可测试(部分引用的第三方类库中的类,类本身属于其他组件,我无权修改,-.-)。
依赖注入与可测试性
如果构造函数有较多逻辑,例如:
class UserReport:
def __init__(self, user_id):
self.user_id = user_id
self.user = database.fetch_user(user_id)
self.activities = database.fetch_user_activities(user_id)
self.statistics = self._calculate_statistics()
那么我们的单元测试会变的成本非常高昂,每一个外部依赖都需要mock,就算只需要测试一个非常简单的Case,也需要模拟所有外部依赖,比如
def test_user_report():
# 需要大量的模拟设置
with patch('module.database.fetch_user') as mock_fetch_user:
with patch('module.database.fetch_user_activities') as mock_fetch_activities:
# 配置模拟返回值
mock_fetch_user.return_value = User(1, "Test User", "[email protected]")
mock_fetch_activities.return_value = [
Activity(1, datetime(2023, 1, 1), "login"),
Activity(1, datetime(2023, 1, 2), "login")
]
# 创建对象 - 即使只是测试一小部分功能也需要模拟所有依赖
report = UserReport(1)
# 验证结果
assert report.statistics["login_count"] == 2
assert report.statistics["active_days"] == 2
# 验证调用
mock_fetch_user.assert_called_once_with(1)
mock_fetch_activities.assert_called_once_with(1)
而构造函数简单,我们的单元测试也会变得非常简单,比如针对下面代码进行测试:
class UserReport:
def __init__(self, user, activities=None):
self.user = user
self.activities = activities or []
self.statistics = {}
def calculate_statistics(self):
"""计算统计数据"""
login_count = len(self.activities)
active_days = len(set(a.date for a in self.activities))
self.statistics = {
"login_count": login_count,
"active_days": active_days
}
return self.statistics
可以看到单元测试不再需要复杂的Mock
def test_report_should_calculate_correct_statistics_when_activities_provided():
# 直接创建测试对象,无需模拟外部依赖
user = User(1, "Test User", "[email protected]")
activities = [
UserActivity(1, datetime(2023, 1, 1), "login"),
UserActivity(1, datetime(2023, 1, 2), "login"),
UserActivity(1, datetime(2023, 1, 2), "logout") # 同一天的另一个活动
]
# 创建对象非常简单
report = UserReport(user, activities)
# 测试特定方法
stats = report.calculate_statistics()
# 验证结果
assert stats["login_count"] == 3
assert stats["active_days"] == 2
同时测试时,Mock对象注入也变得非常简单,如下:
def test_report_should_use_activity_source_when_calculating_statistics():
# 准备测试数据
user = User(42, "Test User", "[email protected]")
mock_activities = [
UserActivity(42, datetime(2023, 1, 1), "login"),
UserActivity(42, datetime(2023, 1, 2), "login")
]
# 创建模拟数据源
activity_source = MockActivity(mock_activities)
# 使用依赖注入
report = UserReport(user)
report.calculate_statistics(activity_source)
# 验证结果
assert report.statistics["login_count"] == 2
assert report.statistics["active_days"] == 2
而做边界值测试时更为简单:
def test_statistics_should_be_empty_when_activities_list_is_empty():
user = User(1, "Test User", "[email protected]")
report = UserReport(user, []) # 空活动列表
stats = report.calculate_statistics()
assert stats["login_count"] == 0
assert stats["active_days"] == 0
def test_constructor_should_throw_exception_when_user_is_null():
# 测试无效用户情况
with pytest.raises(ValueError):
report = UserReport(None) # 假设我们在构造函数中验证用户不为空
因此整个代码逻辑通过单元测试将变得更为健壮,而不是需要大量复杂的Mock,复杂的Mock会导致单元测试非常脆弱(也就是修改一点逻辑,导致现有的单元测试无效)
架构相关影响
更容易依赖注入
依赖注入的核心理念是高层模块不应该依赖于低层模块的实现细节,而应该依赖于抽象。好比我们需要打车去公司上班,我们只要打开滴滴输入目的地,我们更高层次的需求是从A到B,而具体的实现细节是打车过程是哪款车,或者司机是谁,这也不是我们关心的。具体由哪辆车,哪位司机提供服务可以随时切换。
依赖注入是现代软件架构的核心实践之一,而简单的构造函数设计是实现有效依赖注入的基础。通过构造函数注入依赖,我们可以构建松耦合、高内聚的系统,显著提高代码的可维护性和可扩展性。
# 直接在类内部创建依赖
class UserReport:
def __init__(self, user_id):
self.user_id = user_id
# 直接依赖具体实现
self.database = MySQLDatabase()
self.user = self.database.fetch_user(user_id)
# 通过构造函数注入依赖
class UserReport:
def __init__(self, user, activity_source):
self.user = user
self.activity_source = activity_source
self.statistics = {}
def calculate_statistics(self):
activities = self.activity_source.get_activities(self.user.id)
# 计算逻辑...
通过第二段代码可以看到更容易实现依赖注入,通常实际使用中还结合依赖注入容器(IoC)自动化依赖的创建和注入,但这超出本篇的篇幅了。
更容易暴露设计问题
构造函数仅做赋值操作,还能更容易得暴露类的设计问题。当构造函数变得臃肿或复杂时,这通常表明存在更深层次的设计缺陷。
比如一个类的构造函数有大量参数时,通常意味着类承担过多的职责,比如:
# 需要引起警觉:参数过多的构造函数
class UserReport:
def __init__(self, user, activity_list, login_calculator, active_days_calculator,
visualization_tool, report_exporter, notification_system):
self.user = user
self.activity_list = activity_list
self.login_calculator = login_calculator
self.active_days_calculator = active_days_calculator
self.visualization_tool = visualization_tool
self.report_exporter = report_exporter
self.notification_system = notification_system
self.statistics = {}
一个常见的解决思路是使用Builder模式,让初始化过程更加优雅,但这通常只能掩盖问题,而不是解决问题
因此可以将过多参数的构造函数当做red flag,正确的解决办法是重新查看类的设计,进行职责分离:
# 核心报告类,只关注数据和基本统计
class UserReport:
def __init__(self, user, activities):
self.user = user
self.activities = activities
self.statistics = {}
def calculate(self, calculator):
self.statistics = calculator.compute(self.activities)
return self
# 分离的统计计算
class ActivityStatistics:
def compute(self, activities):
login_count = len([a for a in activities if a.action == 'login'])
unique_days = len(set(a.date for a in activities))
return {"logins": login_count, "active_days": unique_days}
# 分离的报告导出功能
class ReportExport:
def to_pdf(self, report):
# PDF导出逻辑
pass
def to_excel(self, report):
# Excel导出逻辑
pass
# 分离的通知功能
class ReportNotification:
def send(self, report, recipients):
# 发送通知逻辑
pass
那么类的调用就会变得非常清晰:
# 清晰的职责分离
user = User(42, "John Doe", "[email protected]")
activities = activity_database.get_user_activities(user.id)
# 创建和计算报告
calculator = ActivityStatistics()
report = UserReport(user, activities).calculate(calculator)
# 导出报告(如果需要)
if export_needed:
exporter = ReportExport()
pdf_file = exporter.to_pdf(report)
# 发送通知(如果需要)
if notify_admin:
notifier = ReportNotification()
notifier.send(report, ["[email protected]"])
这种方式每个类都有明确的单一职责,构造函数简单明了,同时功能可以按需组合使用以及测试变得简单(可以单独测试每个组件)。
特例
某些情况下,构造函数除了赋值,还可以做一些其他工作也是合理的,如下:
参数合法性检查
在构造函数中进行基本的参数验证是合理的,这确保对象从创建之初就处于有效状态,例如下面例子,只要构造函数不进行外部依赖操作或复杂的逻辑运算都是合理的
class User:
def __init__(self, id, name, email):
# 基本参数验证
if id <= 0:
raise ValueError("User ID must be positive")
if not name or not name.strip():
raise ValueError("User name cannot be empty")
if not email or "@" not in email:
raise ValueError("Invalid email format")
self.id = id
self.name = name
self.email = email
简单的派生值计算
有时,在构造函数中计算一些简单的派生值是合理的,只要在整个类声明周期,计算后的值都不变:
class Rectangle:
def __init__(self, width, height):
if width <= 0 or height <= 0:
raise ValueError("Dimensions must be positive")
self.width = width
self.height = height
# 简单的派生值计算
self.area = width * height
self.perimeter = 2 * (width + height)
不可变对象的初始化
对于不可变对象(创建后状态不能改变的对象),构造函数需要完成所有必要的初始化工作:
class ImmutablePoint:
def __init__(self, x, y):
self._x = x
self._y = y
# 预计算常用值
self._distance_from_origin = (x**2 + y**2)**0.5
@property
def x(self):
return self._x
@property
def y(self):
return self._y
@property
def distance_from_origin(self):
return self._distance_from_origin
小结
一个设计合理的构造函数,是打造易维护、易测试、易扩展系统的基础。我们应始终坚持构造函数「仅做赋值和必要的基础验证」这一原则,使代码更为清晰和灵活。
简单的构造函数能带来以下优势:
- 易于维护:职责单一、副作用少,便于后续的调试与迭代。
- 易于测试:不依赖外部环境,能轻松实现模拟和单元测试。
- 架构更清晰:便于实现依赖注入,更符合SOLID原则,也能更快地识别设计上的问题。
当我们发现构造函数开始复杂化,参数越来越多时,这通常是代码设计本身出现了问题,而不是一个能用Builder模式等技巧快速掩盖的问题。正确的做法是退一步重新审视类的职责,及时进行重构。
当然,在实际编码过程中,有时候我们可能会做出一定程度的妥协,例如对参数进行基本合法性检查、简单的数据派生计算,或者初始化不可变对象。这些情况应该是少数的例外,而不是普遍的规则。
总之,通过保持构造函数的简洁和直观,我们不仅能够写出高质量的代码,更能及早发现和解决潜在的设计问题,使整个系统更加稳固和易于维护。