最近在做一些代码重构,涉及到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 = userself.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_useruser_data = self._execute_query(f"SELECT * FROM users WHERE id = {user_id}")File "database.py", line 31, in _execute_queryconnection = self._get_connection()File "database.py", line 15, in _get_connectionreturn 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 Connectreturn 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 connectraise 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 = userself.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.statisticsclass UserActivity:def __init__(self, user_id, date, action):self.user_id = user_idself.date = dateself.action = actionclass 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_idself.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 = userself.activities = activities or []self.statistics = statistics or {}self.recommendations = recommendations or []
而演进时,复杂的构造函数有很大风险,例如:
# 需要修改原有构造函数,风险很高
class UserReport:def __init__(self, user_id, month=None): # 添加新参数self.user_id = user_idself.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 = userself.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_idself.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", "test@example.com")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"] == 2assert 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 = userself.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", "test@example.com")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"] == 3assert stats["active_days"] == 2
同时测试时,Mock对象注入也变得非常简单,如下:
def test_report_should_use_activity_source_when_calculating_statistics():# 准备测试数据user = User(42, "Test User", "test@example.com")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"] == 2assert report.statistics["active_days"] == 2
而做边界值测试时更为简单:
def test_statistics_should_be_empty_when_activities_list_is_empty():user = User(1, "Test User", "test@example.com")report = UserReport(user, []) # 空活动列表stats = report.calculate_statistics()assert stats["login_count"] == 0assert stats["active_days"] == 0def 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 = userself.activity_source = activity_sourceself.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 = userself.activity_list = activity_listself.login_calculator = login_calculator self.active_days_calculator = active_days_calculatorself.visualization_tool = visualization_toolself.report_exporter = report_exporterself.notification_system = notification_systemself.statistics = {}
一个常见的解决思路是使用Builder模式,让初始化过程更加优雅,但这通常只能掩盖问题,而不是解决问题
因此可以将过多参数的构造函数当做red flag,正确的解决办法是重新查看类的设计,进行职责分离:
# 核心报告类,只关注数据和基本统计
class UserReport:def __init__(self, user, activities):self.user = userself.activities = activitiesself.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导出逻辑passdef to_excel(self, report):# Excel导出逻辑pass# 分离的通知功能
class ReportNotification:def send(self, report, recipients):# 发送通知逻辑pass
那么类的调用就会变得非常清晰:
# 清晰的职责分离
user = User(42, "John Doe", "john@example.com")
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, ["admin@example.com"])
这种方式每个类都有明确的单一职责,构造函数简单明了,同时功能可以按需组合使用以及测试变得简单(可以单独测试每个组件)。
特例
某些情况下,构造函数除了赋值,还可以做一些其他工作也是合理的,如下:
参数合法性检查
在构造函数中进行基本的参数验证是合理的,这确保对象从创建之初就处于有效状态,例如下面例子,只要构造函数不进行外部依赖操作或复杂的逻辑运算都是合理的
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 = idself.name = nameself.email = email
简单的派生值计算
有时,在构造函数中计算一些简单的派生值是合理的,只要在整个类声明周期,计算后的值都不变:
class Rectangle:def __init__(self, width, height):if width <= 0 or height <= 0:raise ValueError("Dimensions must be positive")self.width = widthself.height = height# 简单的派生值计算self.area = width * heightself.perimeter = 2 * (width + height)
不可变对象的初始化
对于不可变对象(创建后状态不能改变的对象),构造函数需要完成所有必要的初始化工作:
class ImmutablePoint:def __init__(self, x, y):self._x = xself._y = y# 预计算常用值self._distance_from_origin = (x**2 + y**2)**0.5@propertydef x(self):return self._x@propertydef y(self):return self._y@propertydef distance_from_origin(self):return self._distance_from_origin
小结
一个设计合理的构造函数,是打造易维护、易测试、易扩展系统的基础。我们应始终坚持构造函数「仅做赋值和必要的基础验证」这一原则,使代码更为清晰和灵活。
简单的构造函数能带来以下优势:
-
易于维护:职责单一、副作用少,便于后续的调试与迭代。
-
易于测试:不依赖外部环境,能轻松实现模拟和单元测试。
-
架构更清晰:便于实现依赖注入,更符合SOLID原则,也能更快地识别设计上的问题。
当我们发现构造函数开始复杂化,参数越来越多时,这通常是代码设计本身出现了问题,而不是一个能用Builder模式等技巧快速掩盖的问题。正确的做法是退一步重新审视类的职责,及时进行重构。
当然,在实际编码过程中,有时候我们可能会做出一定程度的妥协,例如对参数进行基本合法性检查、简单的数据派生计算,或者初始化不可变对象。这些情况应该是少数的例外,而不是普遍的规则。
总之,通过保持构造函数的简洁和直观,我们不仅能够写出高质量的代码,更能及早发现和解决潜在的设计问题,使整个系统更加稳固和易于维护。