動態

詳情 返回 返回

為什麼構造函數需要儘可能的簡單 - 動態 詳情

    最近在做一些代碼重構,涉及到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模式等技巧快速掩蓋的問題。正確的做法是退一步重新審視類的職責,及時進行重構。

當然,在實際編碼過程中,有時候我們可能會做出一定程度的妥協,例如對參數進行基本合法性檢查、簡單的數據派生計算,或者初始化不可變對象。這些情況應該是少數的例外,而不是普遍的規則。

總之,通過保持構造函數的簡潔和直觀,我們不僅能夠寫出高質量的代碼,更能及早發現和解決潛在的設計問題,使整個系統更加穩固和易於維護。

 

user avatar Dengsiyan 頭像 tonnyking 頭像 cikiss 頭像 gtyan 頭像
點贊 4 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.