C++物件模型
話題從下面這段C++程式說起,你認為它可以順利執行嗎?
//C++
class A {
public:
void Hello(const std::string& name) {
std::cout << "hello " << name;
}
};
int main(int argc, char** argv) {
A* pa = NULL; //!!
pa->Hello("world");
return 0;
}
試試的確可以順利運行輸出hello world,奇怪嗎?其實並不奇怪,根據C++物件模型,類的非虛方法並不會存在於對象記憶體布局中,實際上編譯器是把Hello方法轉化成了類似這樣的全域函數:
void A_Hello_xxx(A * const this, const std::string& name) {
std::cout << “hello “ << name;
}
對象指標其實是作為第一個參數被隱式傳遞的,pa->Hello(“world”)實際上是調用的A_Hello_xxx(pa, “world”),而恰好A_Hello_xxx內部沒有使用pa,所以這段代碼得以順利運行。
對象的訊息模型
如果是研究C++物件模型,上面的討論可以到此為止,不過這裡我想從另一個角度來繼續探討這個問題。OOP的先驅人物Alan Kay在總結Smalltalk的OO特徵時強調:
Smalltalk is not only NOT its syntax or the class library, it is not even about classes. I'm sorry that I long ago coined the term "objects" for this topic because it gets many people to focus on the lesser idea. The big idea is "messaging".
也就是說相比類和對象的概念來講,他認為對象互動的訊息模型是OOP更為本質的特徵,因為訊息關注的是對象間的介面和互動,在構建大的系統的時候重要的不是對象的內部狀態,而是它們的互動。根據訊息模型,牛.吃(草) 的語義是發送一條訊息給“牛”,訊息的類型是“吃”,訊息的內容是“草”。如果按照嚴格的訊息模型,那麼上面那段C++代碼應解釋為向一個NULL對象發送Hello訊息,這顯然是不應該順利執行的。類似的代碼如果是在Java或C#中則會拋出Null 參考異常,所以Java和C#的設計更符合訊息模型。
不過,Java和C#中也並非完全符合訊息模型,來看一個經典的封裝問題:
//C#
public class Account {
private int _amount;
public void Transfer(Account acc, int delta) {
acc._amount += delta;
this._amount -= delta;
}
…
}
上面定義了一個Account類,問題在於為什麼在這個類的Transfer方法中可以直接存取另一個對象acc的私人成員_amount呢?這是不是有破壞封裝的嫌疑呢?這個問題經典的答案是:並不破壞封裝,封裝是劃分了基於類的靜態代碼邊界,使得類的private代碼修改不影響外界,而不是對於動態對象的保護。這個解釋當然是合理的,不過正如上面C++代碼的解釋屬於C++物件模型範疇,這個解釋則屬於基於類的靜態類型OOP語言的範疇。訊息模型強調了對象內部狀態的保護,只能通過訊息改變其狀態,而對象內部是否真的具有_amout這樣一個私人成員對其他任何對象(即使同類對象)都是未知的。
如果要嚴格遵守訊息模型實現對象內部狀態的保護應該怎麼做呢?我們來看一個例子,定義一個集合類,包括:1.集合對象的建構函式;2.In方法:判斷元素是否存在;3.Join方法:對兩個集合做交集;4.Union方法:對兩個集合做並集。下面是一種Javascript實現:
//Javascript
//集合類Set的建構函式
function Set() {
var _elements = arguments;
//In方法:判斷元素e是否在集合中
this.In = function(e) {
for (var i = 0; i < _elements.length; ++i) {
if (_elements[i] == e) return true;
}
return false;
};
}
//Join方法:對兩個集合求交集
Set.prototype.Join = function(s2) {
var s1 = this;
var s = new Set();
s.In = function(e) { return s1.In(e) && s2.In(e); }
return s;
};
//Union方法:對兩個集合求並集
Set.prototype.Union = function(s2) {
var s1 = this;
var s = new Set();
s.In = function(e) { return s1.In(e) || s2.In(e); }
return s;
};
var s1 = new Set(1, 2, 3, 4, 5);
var s2 = new Set(2, 3, 4, 5, 6);
var s3 = new Set(3, 4, 5, 6, 7);
assert(false == s1.Join(s2).Join(s3).In(2));
assert(true == s1.Join(s2).Uion(s3).In(7));
如果是在靜態類型OOP語言中,要實現集合類的Join或Union,我們多半會像上面Account的例子一樣直接對s2內部的_elements進行操作,而上面這段Javascript定義的Set關於對象s2的訪問完全是符合訊息模型的基於介面的訪問。要實現訊息模型Javascript的prototype機制並非必須的,真正的關鍵在於函數式的高階函數和閉包特性。從這個例子我們也可以體會到函數式的優點不僅在於無副作用,函數的可組合性也是函數式編程強大的原因。
Method Missing
接下來我們還要進行深度曆險,讓我們思考一下如果發送一條對象不能識別的訊息會怎樣?這種情況在C++、Java、C#等靜態語言中會得到一個方法未定義的編譯錯誤,如果是在Javascript中則會產生運行時異常。比如,s1.count()會產生一個運行時異常:Object #<Set> has no method 'count'。
在靜態語言這個問題很少受到重視,但在動態語言中卻大有文章,來看下面的例子:
//Ruby
builder = Builder::XmlMarkup.new
xml = builder.books {|b|
b.book :isbn => "14134" do
b.title "Revelation Space"
b.author "Alastair Reynolds"
end
b.book :isbn => "53534" do
b.title "Accelerando"
b.author "Charles Stross"
end
}
上面這段很DSL的Ruby代碼建立了這樣一個XML檔案對象:
<books>
<book isbn="14134">
<title>Revelation Space</title>
<author>Alastair Reynolds</author>
</book>
<book isbn="53534">
<title>Accelerando</title>
<author>Charles Stross</author>
</book>
</books>
builder.books, b.book, b.title都是對象方法調用,由於XML的元素名是任意的,所以不可能事先定義這些方法,類似的代碼如果是在Javascript中就是no method異常。那為什麼上面的Ruby代碼可以正確執行呢?其實只要理解了訊息模型就很容易想明白,只需要定義一個通用的訊息處理方法,所有未明確定義的訊息都交給它來處理就行了,這就是所謂的Method Missing模式:
class Foo
def method_missing(method, *args, &block)
…
end
end
Method Missing除了對實現DSL很重要外,還可用於產生更好地調試和錯誤資訊,把參數嵌入到方法名中等場合。目前,Ruby、Python、Groovy幾種語言對Method Missing都有很好的支援,甚至在C# 4.0中也可以利用動態特性實現。
總結
本文主要介紹了對象的訊息模型的特徵,並比較了C++物件模型,Java、C#等基於類的靜態語言中的物件模型與嚴格訊息模型的差異,最後探討了Method Missing相關話題。
參考
Inside the C++ Object Model
冒號課堂 - 編程範式與OOP思想
Alan Kays Definition Of Object Oriented
OOP The Good Parts: Message Passing, Duck Typing, Object Composition, and not Inheritance
Patterns of Method Missing
Fun With Method Missing and C# 4