控制反轉
不懂ioc只會把DI用成臃腫的工廠模式——比如XML,JSON,TXT的格式轉換,在裡面new了一堆內容,並用列舉switch來判斷——但完全可以ioc,定義一個泛型,讓外部給入,在裡面new T——完全不需要列舉。有些人說ioc才是DI的精髓,我看也不是沒有道理。
比如下面的程式碼,就是雖然注入了,控制權卻還是內部的——外面通過列舉來控制轉換方法的內部行為,這種情況下看上去好像是控制權在外部,但事實上這是一種間接的偽控制,真正的控制權仍然在內部,你只是給了一個切換訊號而已。
// 注入了,但控制沒有反轉
class ConversionService {
public string Convert(string input, FormatEnum format) {
switch (format) {
case FormatEnum.XML: return new XmlConverter().Convert(input);
case FormatEnum.JSON: return new JsonConverter().Convert(input);
// 每加一種格式就改這裡——開閉原則直接破功
}
}
}
而下面是引入了泛型的真·IoC,控制權完全反轉。
interface IConverter {
string Convert(string input);
}
class ConversionService<T> where T : IConverter, new() {
public string Convert(string input) {
var converter = new T(); // 具體型別由外部決定
return converter.Convert(input);
}
}
// 呼叫方
var svc = new ConversionService<XmlConverter>();
我覺得這裡有一點很重要,那就是遙控和外部給T看上去都是控制,但卻有一個至關重要的不同。前者是選擇,邊界本質上是方法內部給定的,你能做的就是在已有的幾個選項裡面選,實際上控制權仍然在內部。而IoC則是徹底的放手:不關心你給的是什麼,也不關心邊界,只對介面(結構)負責。也就是説,XML,JSON,TXT這是三個,遙控也只能選擇這三個;而IoC就是無限,只要你合規,那傳什麼都行——這才是真正的控制反轉,只對介面負責的放手。
如果用廚房和廚師來比喻的話,前者就是廚房自帶廚師,但只有三位:川菜、湘菜、本幫菜。那我想吃河南菜呢?不好意思,沒有。而IoC就是:提供廚房,廚師自備。河南菜?沒問題。東北菜?沒問題。西餐?當然沒問題——但前提是用中式廚具,畢竟廚房(介面)是固定的。總之,控制反轉的關鍵就在此,提供了一種最大的靈活性和抽象,但也不是毫無約束的:定義介面至關重要。
而這裡也能看出來介面設計的重要性到底在哪裡。如果給的太寬,那就是啥都能行,結果也是行為不可預料,反而造成了一種耦合。這裡的問題其實和繼承一樣,那就是存在隱式依賴問題。你根本不知道子類用了啥,也許基類改了個無關緊要的東西,結果下面炸了。因此,介面設計需要的是在靈活性和約束之間權衡,一個良好的介面必須約定明確且邊界清晰。否則模糊的介面設計之下,IoC也救不了你的耦合。
而還有一種更極緻的用法是跟反射結合,在編譯期都可以完全不指定型別。但這裡有代價,如果我寫 where T : IConverter ,那好得是規定了介面的結構到底啥樣,如果寫的有問題,編譯期間就會報錯。而反射的話,我只能進行型別轉換——那就是我和你約定,這裡需要是這個型別,但你到底是不是真用這個型別,我是沒法硬性限制的。
Type type = Type.GetType("MyApp.XmlConverter");
IConverter converter = (IConverter)Activator.CreateInstance(type);
// 如果 XmlConverter 根本沒實現 IConverter,這裡直接 InvalidCastException
比如這裡,萬一給進來的就是不符合約定,那只有執行的時候才知道錯誤。但如果我用的是泛型,那編譯期間就會直接報錯提示,根本就不會到最後階段。因此反射其實多少有了點動態語言的味道——就跟python一樣,不到執行的時候你是不知道會不會報錯的。這也是靈活性和穩健性的權衡,在有些場景下確實需要靈活性,但必須明確的知道其代價和邊界。如果可以的話,提前在這種靈活性上做好錯誤封裝:就算結果不可預料,也要控制損失範圍,使其盡可能最小化。