為什么構造函數需要盡可能的簡單
最近在做一些代碼重構,涉及到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", "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"] == 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", "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"] == 3
assert 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"] == 2
assert 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"] == 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", "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 = 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模式等技巧快速掩蓋的問題。正確的做法是退一步重新審視類的職責,及時進行重構。
當然,在實際編碼過程中,有時候我們可能會做出一定程度的妥協,例如對參數進行基本合法性檢查、簡單的數據派生計算,或者初始化不可變對象。這些情況應該是少數的例外,而不是普遍的規則。
總之,通過保持構造函數的簡潔和直觀,我們不僅能夠寫出高質量的代碼,更能及早發現和解決潛在的設計問題,使整個系統更加穩固和易于維護。
浙公網安備 33010602011771號