在實際項目中,經常能夠看到容器被當作參數,在不同的對象間傳遞。這樣做有什么問題?
缺乏內聚性
在進一步討論之前,我們先來看看下面兩個表達式之間有何區別?
int value;
std::list<int> values;
經常得到的答案是:前者是一個primitive
的數據,后者是一個對象。對于前者,你只能執行基本的數值演算;而后者的類型std::list<int>
是一個類,你可以調用它的方法,比如:
values.push_back(5);
這個答案并沒有什么錯。那我們再來看一個問題:下面兩個表達式的區別在哪里?
Object object;
std::list<Object> objects;
對于這個問題,之前的答案就不再有效。因為在這個例子中,兩者都是對象。其不同之處在于,對Object
對象的方法調用,是對一個具體的業務對象的操作;而后者卻是對容器對象的操作。
現在,我們將問題變為:這兩個例子中,前后兩個表達式的共同差異是什么?
如果你比較敏銳,應該已經得到答案:前者代表一個數據(對象),后者代表一組數據(對象)。
所以,雖然容器本身是一個對象,但更本質地,它代表著一組數據,圍繞著這組數據的業務邏輯,容器對象本身并沒有涉及。
所以,直接訪問一個容器,而不是將容器封裝在一個業務對象里;這和直接操作一個數據,而不是將數據封裝在一個抽象數據類型里,本質上沒有任何區別。它們都違反了數據和操作它的行為應該放在一起的高內聚原則。
缺乏穩定性
現在,我們再問一個問題:下面的三個表達式的共同之處是什么?
Object objects[100];
std::vector<Object> objects;
std::list<Object> objects;
答案很簡單:都代表多個Object
對象的集合。它們之間的實現技術上雖然不同,但從抽象層面上,這三個實現方式所要表達的概念并無任何不同。
實現技術可以隨著約束的變化而變化,但只要用戶的抽象需求沒有發生變化,用戶的代碼就不應該受到具體實現技術變化的影響。
因此,直接讓用戶訪問容器對象,不僅僅違反了高內聚的原則,還違反了“向著穩定的方向依賴”原則。
對容器進行封裝
基于上述的討論,我們可以得出如下結論:當系統中存在一個集合概念時,應考慮包含這個集合概念的單一概念是什么,并根據這個單一概念對集合進行封裝。
比如:一個班包含許多學生。糟糕的做法是:
typedef std::list<Student> SchoolClass;
當在另外一個對象需要計算一個班的平均成績時,就會出現類似于下面的代碼:
struct Foo
{
void f(const SchoolClass& cls)
{
unsigned int averageScore = getAverageScoreOfClass(cls);
// ...
}
private:
unsigned int getAverageScoreOfClass(const SchoolClass& cls)
{
unsigned int totalScore = 0;
for( SchoolClass::const_iterator i=cls.begin(); i != cls.end(); ++i)
{
totalScore += (*i).getScore();
}
return totalScore/cls.size();
}
// ...
};
而一個合理的做法則是:
struct SchoolClass
{
unsigned int getAverageScore() const
{
// ...
}
// ...
private:
std::list<Student> students;
};
struct Foo
{
void f(const SchoolClass& cls)
{
unsigned int averageScore = cls.getAverageScore();
// ...
}
// ...
};
如果有一天,設計者認為使用定長數組是更好的選擇(因為std::list
有可能因為內存問題而帶來的不確定性),那么所有的修改都被控制在SchoolClass
內部,對于Foo
,以及任何其它SchoolClass
的客戶都毫無影響(局部化影響)。
多級容器
另外,在實際項目中,經常能夠看到類似于下面的定義:
typedef std::map<std::string, std::map<std::string, std::string>
> ConfigFile;
這還算輕微的。事實上,在我經歷過的項目中,三級甚至四級容器也并不罕見。
相對于單級容器,多級容器帶來的問題更多:這樣復雜的數據結構定義本身就非?;逎?,而其處理代碼也往往互相交織在一起,不僅難以理解,還極其脆弱:其中任何一個級別的容器發生變化都會給整個數據結構的處理代碼帶來影響。
比如,上述數據結構完全可以改變為:
typedef std::map<std::string, std::list<std::pair<std::string, std::string> >
> ConfigFile;
對于多級容器,其處理方法和單級容器的方法并沒有什么兩樣:將每一級容器都進行封裝。比如,對于剛才這個例子,至少可以進行類似于下面的封裝:
struct ConfigFile
{
// ...
private:
std::map<std::string, ConfigSection> sections;
};
struct ConfigSection
{
// ...
private:
std::map<std::string, std::string> items;
};
用意不明的數據子集
當一個數據集合被封裝在一個類中之后,對于這個數據集合的需求可能變化非常劇烈。比如,客戶代碼可以基于各種各樣的目的,從數據集合中過濾出一個數據子集,并對這個數據子集執行自己所需的操作。
如果將所有客戶的意圖,都堆積在數據集合所在的類中實現,將會造成這個類極其不穩定,也容易造成上帝類。同時,也會降低客戶代碼的內聚度。
這種情況下,數據集合類提供查詢接口,由客戶自定義一個過濾條件,數據集合類根據客戶自定義的過濾條件,得到客戶所需的數據子集,由客戶代碼對數據子集定義所需的操作,反而是個更好的選擇。
對于數據集合類而言,這些數據子集的語意是不明的,因為客戶才知道它的用途。所以,如果需要對這些數據子集進行封裝的話,也應該是客戶的責任。如果客戶將數據子集封裝為語意明確的類,并將這個類作為輸出參數傳遞給數據集合類的話,既會造成數據集合類對這些數據子集類型的依賴,同時仍然會造成數據集合類接口的不穩定。
所以,設計者們往往選擇給數據集合類提供類似與下面的接口與實現:
struct SchoolClass
{
void getStudentsByFilter
( const Filter& filter // 輸入參數:過濾器
, std::list<Student>& result // 輸出參數:查詢結果
) const
{
for( SchoolClass::const_iterator i=cls.begin() ; i != cls.end(); ++i)
{
if(filter.matches(*i))
{
result.push_back(*i);
}
}
}
// ...
private:
std::list<Student> students;
};
這樣的方法,幾乎可以保證數據集合類接口和實現的穩定。之所以說“幾乎”,是因為std::list
作為雙方交換數據的契約,仍然過于具體。一旦因為某種原因發生變化,則雙方代碼都會受到影響。
但是,我們之前已經得出過結論:std::list
雖然很具體,但也不能對其進行業務層面的封裝。我們似乎陷入了黔驢技窮的處境。
5 Why
分析法告訴我們,如果我們多問幾個為什么,就能找到更加穩定的抽象。
客戶在拿到數據子集之后,一定有自己的意圖,我們如果讓接口反映的是用戶自己的意圖,而不是數據子集這么具體的實現細節,那么數據子集將會變成一個無用的中間層。
那客戶的意圖是什么呢?不知道。但我們有多態這門進行抽象的強大武器,借助于它,客戶的確切意圖對我們便不再重要。
所以,我們可以將上述代碼修改為:
struct Visitor
{
virtual void visit(const Student& student) = 0;
virtual ~Visitor {}
};
struct SchoolClass
{
void visitStudentsByFilter
( const Filter& filter // 輸入參數:過濾器
, Visitor& visitor // 輸入參數:對過濾結果的處理
) const
{
for( SchoolClass::const_iterator i = cls.begin() ; i != cls.end(); ++i)
{
if(filter.matches(*i))
{
visitor.visit(*i);
}
}
}
// ...
private:
std::list<Student> students;
};
這樣的實現方式,幫助我們更加直接的滿足客戶的意圖。這不僅讓雙方的代碼更加穩定,在很多場合下,由于客戶并不需要存儲查詢的結果,繞開std::list
這樣的數據集合,還可以提高性能,并降低內存管理方面的負擔。
比如,一個客戶想過濾出所有及格的學生,只是為了統計及格學生的數量,那么它就可以將Visitor
實現為:
unsigned int Foo::getNumOfPassStudents(const SchoolClass& cls) const
{
struct PassStudentFilter : Filter
{
bool matches(const Student& student) const
{
return student.isPass();
}
} filter;
struct PassStudentsCounter : Visitor
{
PassStudentsCounter() : numOfPassStudents(0) {}
void visit(const Student& student) { numOfPassStudents++; }
unsigned int numOfPassStudents;
} counter;
cls.visitStudentsByFilter(filter, counter);
return counter.numOfPassStudents;
}
通過這個實現,我們注意到一個重要事實:Filter
是不必要的,因為客戶可以在Visitor
里自己進行過濾。所以,我們將之前數據集合類的實現修改為簡化版本的訪問者模式(由于沒有多種類型的元素,所以不需要雙重派發)。而這是一個更加通用的抽象,借助于它,可以簡化雙方的實現。
struct SchoolClass
{
void accept(Visitor& visitor) const
{
for( SchoolClass::const_iterator i=cls.begin() ; i != cls.end(); ++i)
{
visitor.visit(*i);
}
}
// ...
private:
std::list<Student> students;
};
而之前的客戶代碼也得到簡化:
unsigned intFoo::getNumOfPassStudents(const SchoolClass& cls) const
{
struct PassStudentsCounter : Visitor
{
PassStudentsCounter() : numOfPassStudents(0) {}
void visit(const Student& student)
{
if(student.isPass()) numOfPassStudents++;
}
unsigned int numOfPassStudents;
} counter;
cls.accept(counter);
return counter.numOfPassStudents;
}
而對于確實需要保存下來過濾結果的客戶,仍然可以輕松達到目標:
struct Bar : private Visitor
{
void savePassedStudents(const SchoolClass& cls)
{
cls.accept(*this);
}
// ...
private: // 對 visit 方法的實現
void visit(const Student& student)
{
if(student.isPass()) passedStudents.push_back(student);
}
private:
// 注意,這不是 std::list,而是用戶根據自己需要而采用的數據結構
std::vector<Student> passedStudents;
// ...
};
在這個實現中,使用了私有繼承。關于其用法的詳細討論,請參考《Virtues of Bastard》。
總結
本文探討了直接暴露容器所帶來的問題,以及如何進行封裝,以提高可維護性。關于封裝,請參考《類與封裝》。