我在之前的文章裡談論過指派語句的危害性。 使用指派語句會使程式變的冗長,更難了解。但事實上,指派語句對程式設計來說是一種基本語句,想限制它的使用幾乎是不可能的。幸運的是,我們實際上是能做到的,下面我就會向你展示如何去做。
用正确的方式初始化
// 錯誤 | // 正确
int x; |
// ... | // ...
x = init(); | int x = init();
“正确”方式的主要優點是你能很友善的浏覽
x
的定義的同時知道它的值。這樣也能保證
x
始終處在一個固定變量狀态,大多數的編譯器都能檢測到這種狀态。其次,這樣可以使代碼減少備援。
“錯誤”方式之是以存在完全是因為很多老式的程式設計語言都強制要求在程式的開始處先聲明變量。這樣編譯器好處理。但現在這已經不是問題了,即使在C語言裡。
構造新資料
// 錯誤 | // 正确
int x = init(); | int x = init();
// ... | // ...
x = something(); | int y = something();
這樣做很重要。它能保證變量被定義後不會被改變。不留任何機會。
x
的值我們可以保證它是通過
init()
初始化的值。
人們使用“錯誤”方式一般有兩個原因:高效和簡潔。效率并不是個問題,現代編譯器能夠通過給變量重新配置設定位址來優化性能。而由于簡潔而導緻的語義模糊是得不償失的。
用函數,不要用過程
// 錯誤 | // 正确
void to_utf8(string s); | string to_utf8(string s);
|
// ... | // ...
|
string s1 = latin(); | use_string(to_utf8(latin()))
to_utf8(s1); |
use_string(s1); |
“正确”方式使用的是一個普通的數字函數:它接受輸入值,傳回計算後的值。另一邊,“錯誤”方式使用了過程 。跟函數不一樣,過程不僅會影響傳回的結果,還能影響其它資料,例如,過程中可以修改它的參數值。這會使這些參數很容易被污染。
是以,當能夠使用函數的時候,盡量不要使用過程。你的程式這樣會變得更簡單。這種技巧可以讓你避免去思考如何去做 (變換一個字元串),而是如何被做 (一個變換了的字元串)。要着眼于最終結果,而不是處理過程。
“錯誤”方式之是以存在完全是由于許多老的程式設計語言很難處理複雜的傳回值。你隻能傳回單個數字。是以,當需要一個内容更豐富的傳回值時,你隻能在過 程中達到這個目的。而真正的傳回值通常是一些簡單的錯誤标号代碼。然而現在不同了,傳回複雜的結果已經不再是個問題。即使是在C語言裡你也可以傳回複雜的 結果。
固化你的對象
在很多的入門級的介紹面向對象程式設計的課程中,你能看到這樣一個著名的二維坐标的例子:
// 非常非常錯誤
class Point
{
public:
// constructor
Point() { x = 0; y = 0; }
float get_x() { return x; }
float get_y() { return y; }
void set_x(float new_x) { x = new_x; }
void set_y(float new_y) { y = new_y; }
move(Point p) {
x = x + p.x;
y = y + p.y;
}
private:
float x; float y;
};
這樣設計的原因很簡單:你可以通過構造函數建立一個新的坐标,然後通過
set_x()
和
set_y()
進行初始化。内部資料是經過封裝的(
private
),隻能通過
get_x()
和
get_y()
來通路。還有個好處是,你可以通過
move()
方法來移動這個坐标點。
然而,從代碼本身看,卻是沒必要的複雜化了,而且有幾個主要的錯誤:
- 構造函數直接把
和x
初始化成0了。如果你希望它是其它值,你還需要手工的設定。你不能初始時做到這些 。y
- 操作一個點的預設方法就是修改它。這是一種指派操作。你被限制了建立一個新值 。
-
,set_x()
, 和set_y()
方法現場修改這個對象。這些是過程 , 不是函數 。move()
-
(和x
) 是私有的,但你可以通過y
和get_x()
操作它們。是以,你認為你是封裝了它們,而實際上沒有。set_x()
-
這個方法不需要放在move()
類裡。放在類裡使類的體積變大,影響了解和使用。Point
正确的設計更簡單,而且不失功能:
// 正确的
class Point
{
public:
// constructor
Point (float x, float y) {
_x = x; _y = y;
}
x() { return x; }
y() { return y; }
private:
float _x; float _y
}
Point move(Point p1, Point p2) {
return Point(p1.x() + p2.x(),
p1.y() + p2.y());
}
另外,如果你願意,你可以把
_x
和
_y
聲明成public和常量。
使用純功能性資料結構
從上面的介紹裡我們說明了應該建構新資料 。這個建議即使是大資料結構也是有效的。意外嗎,它并不是像你想象的那樣失去作用。有時候你為了避免每次都拷貝整個資料結構,你可能要使用修改操作。而數組和hash table就是屬于這種情況的。
這種情況下你應該是使用我們所謂的純功能性資料結構。如果你想對這有所了解,Chris Okasaki’s thesis (也是同名著作)是個好的教材。這裡,我隻給大家簡單的講講linked list。
一個連結表要麼是個空表,要麼是其中有個單元格存着一個指向另一個表的指針。
┌───┬───┐ ┌───┬───┐
│ x │ ───> │ y │ ───> empty
└───┴───┘ └───┴───┘
這樣的資料結構如果在ML語言裡是很好設計出來的,但在以類為基礎的語言裡會稍微有點複雜:
-- Haskell
-- A list is either the Empty list,
-- or it contains an Int and a List
data List = Empty
| NotEmpty Int List
-- utility functions
is_empty Empty = true
is_empty NotEmpty x xs = false
head Empty = error
head NotEmpty x xs = x
tail Empty = error
tail notEmpty x xs = xs
// Java(ish)
class List
{
public:
// constructors
List() { _is_empty = true; }
List(int i, List next) {
_i = i;
_next = next;
_is_empty = false;
}
bool is_empty() { return _is_empty; }
int head() {
if (_is_empty) error();
return _i;
}
List tail() {
if (_is_empty) error();
return _next;
}
private:
int _i;
List _next;
bool _is_empty;
}
你可以看到,現在這個List類是不可變的。我們不能修改
List
對象。我們隻能在現有的對象外建立新的List。 這很容易 。因為當你建構一個新List時,它會共享現有的大多數的單元。假設我們有個list
l
,和一個整數
i
:
┌───┬───┐ ┌───┬───┐
l = │ x │ ───> │ y │ ───> empty
└───┴───┘ └───┴───┘
i = 42
此時,在l的頂部加入
i
,這樣就會産生一個新的list
l2
:
┌───┬───┐
l2 = │ i │ │
└───┴─│─┘
│
│ ┌───┬───┐ ┌───┬───┐
l = └──>│ x │ ───> │ y │ ───> empty
└───┴───┘ └───┴───┘
或者,在代碼裡:
List l = List(x, List(y, List()));
int i = 42;
List l2 = List(i, l); // cheap
l
仍然存在,不可變,而建立的
l2
隻是多了一個建立的單元。類似的,删除頂部的元素也是不費任何資源的容易。
當我們不能這樣做時
有時,你不能避免指派操作,或者受其它因素限制。也許是你需要更高的效率,你必須修改資料狀态來優化程式。或者由于一些外界因素影響,比如一個使用者。或者由于你使用的語言不能自動處理記憶體使用,這些都會阻止你使用純功能性的資料結構 。
這種情況下你所能做的最好的方式是隔離那些程式中不合規範的代碼(那些使用指派語句的代碼)。比如說,你想給一個數組排序,你必須用quicksort。Quicksort嚴重的依賴于變換轉移操作,但是你可以隐藏這些操作:
array pure_sort (array a)
{
array a2 = copy(a);
quicksort(a2); // modify a2, nothing else
return a2;
}
于是,當
pure_sort()
這個内部函數不能按照我的建議的去寫時,影響并不大,因為它被限制在函數内了。最終,
pure_sort()的
行為就像是個普通的函數 了。
相反的,當你與其它業務有互動時,要小心的将互動部分的代碼和運算部分的代碼分隔開。比如你要寫段在螢幕上畫個點的程式,而且能根據滑鼠的移動而移動。寫出來可能會是這樣:
// 錯誤
Point p(0, 0);
wile(true) // loop forever
{
p = move(p, get_mouse_movement());
if (p.x() < 0 ) p = Point(0 , p.y());
if (p.x() > 1024) p = Point(1024 , p.y());
if (p.y() < 0 ) p = Point(p.x(), 0 );
if (p.y() > 768 ) p = Point(p.x(), 768 );
draw(p);
}
這裡有個錯誤,它在主程式裡對越界坐标進行了檢查。更好的方式是這樣:
// 正确
point smart_move(point p, point mouse_movement)
{
float x = p.x() < 0 ? 0
: p.x() > 1024 ? 1024
: p.x();
float y = p.y() < 0 ? 0
: p.y() > 768 ? 768
: p.y();
return Point(x, y);
}
// 主程式
Point p(0, 0);
wile(true) // loop forever
{
p = smart_move(p, get_mouse_movement());
draw(p);
}
現在,主程式變得更簡單了。運算部分,
smart_move()
,可以進行單獨測試,甚至可以在其它地方重用。 現在,如果你不喜歡這樣的三元操作的文法,不想按我介紹的規則,不去構造新資料 :
// 這樣也不是很差
point smart_move(point p, point mouse_movement)
{
float x = p.x();
float y = p.y();
if (x < 0 ) x = 0;
if (x > 1024) x = 1024;
if (y < 0 ) y = 0;
if (y > 768 ) y = 768;
return Point(x, y);
}
不管你怎麼寫,
smart_move()
始終應該是個函數 。
結論
我說的這些都是關于降低耦合的技巧。每個程式都應該有很清晰的内部邊界。每個子產品應暴露最少量的接口。這能使程式更易于了解和使用。避免使用指派語句,堅持對象恒定的原則能使接口清晰明确。但這也不是銀彈,這隻是輔助手段。很有用的輔助手段。
譯文來源:外刊IT評論
:)