每隔一段時間,偶而就會聽到一些靜態函式的都市傳說,比較誇張點的是非靜態函式(也就是類別成員函式),會依據物件而複製,所以占用記憶體較多。較為貼近合理情況的是靜態函式的執行速度優於非靜態函式,如果你相信我的話,以下就是答案。
1. 非靜態函式(也就是類別成員函式),會依據物件而複製,所以占用記憶體較多
我所知道的編譯式、物件導向語言,沒有一個會這樣做,所以這是錯的。
2. 靜態函式的執行速度優於非靜態函式
是,不用懷疑。
3. 所以追求效能應該把非靜態改成靜態
不是,因為強制把非靜態改成靜態,裡面的邏輯會把你得到的非靜態改靜態優勢吃掉,所以正負得零
4. 那何時該用靜態函式,何時該用非靜態函式,有準則嗎?
施主,這該問你自己。
這裡我用C# 做為例子,就我記憶所及,Java與C++也是一樣的邏輯,這是物件導向程式語言編譯器的通則,先從非靜態函式是否會依據物件而複製開始談起,驗證的方法很簡單,只要寫一個小類別就可以了。
class SimpleClass
{
public int Sum(int x, int y) => x + y;
}
static void Main(string[] args)
{
var c1 = new SimpleClass();
var c2 = new SimpleClass();
c1.Sum(12, 13);
c2.Sum(13, 12);
Console.Read();
}
想找到證據,我們得進入機器語言的層次,這是唯一不會騙人的程式語言。
Call就是函式呼叫的部分,可以看到是同一個位址,所以這證明了非靜態函式不會依據物件的數量複製。
靜態函式比非靜態函式快,當然,但是差距非常小,這得從函式在編譯器中如何呈現開始說起,靜態函式與非靜態函式在編譯器眼中都是函式(廢話),差別在於參數數目,也就是this,靜態函式不需要傳入this,而非靜態函式需要傳入this,是的,差距就是一個參數而已,就編譯器角度而言,沒有非靜態函式這種東西,所有的函式都是靜態的,以下面這個例子為例。
class Class1
{
public int Sum(int x, int y) => x + y;
public static int Sum2(int x, int y) => x + y;
}
static void Benchmark(Action a, string name)
{
Stopwatch sw = new Stopwatch();
sw.Start();
a();
sw.Stop();
Console.WriteLine($"{name} : {sw.ElapsedMilliseconds}");
}
static void Main(string[] args)
{
Benchmark(() =>
{
var c1 = new Class1();
for (int i = 0; i < 1000000000; i++)
c1.Sum(i, i + 2);
}, "instance");
Benchmark(() =>
{
for (int i = 0; i < 1000000000; i++)
Class1.Sum2(i, i + 2);
}, "static");
Benchmark(() =>
{
var c1 = new Class1();
for (int i = 0; i < 1000000000; i++)
c1.Sum(i, i + 2);
}, "instance");
Benchmark(() =>
{
for (int i = 0; i < 1000000000; i++)
Class1.Sum2(i, i + 2);
}, "static");
Console.Read();
}
以下是執行結果。
這個程式有兩個問題,第一個是首次執行比較慢,這是因為JIT編譯器介入的關係,在.NET 中,所有的函式在未執行時都只有一行程式碼,稱為JIT Stub,當該函式被執行時,JIT Stub會跳到JIT編譯器中編譯該函式的IL code,然後反向Patch JIT Stub,完成後下次就是直接執行機器碼了,這個過程比較複雜,不在本文章討論的範圍內,這裡就用一張圖解釋,日後有機會再說。
第二件事是靜態函式比非靜態函式快,這是因為參數的數目根本就不對等,非靜態函式需要多接收一個this物件,以這個例子來說,要改成下面這樣才公平。
class Class1
{
public int Sum(int x, int y) => x + y;
public static int Sum2(int x, int y) => x + y;
public static int Sum3(Class1 c, int x, int y) => x + y;
}
static void Benchmark(Action a, string name)
{
Stopwatch sw = new Stopwatch();
sw.Start();
a();
sw.Stop();
Console.WriteLine($"{name} : {sw.ElapsedMilliseconds}");
}
static void Main(string[] args)
{
Benchmark(() =>
{
var c1 = new Class1();
for (int i = 0; i < 1000000000; i++)
c1.Sum(i, i + 2);
}, "instance");
Benchmark(() =>
{
for (int i = 0; i < 1000000000; i++)
Class1.Sum2(i, i + 2);
}, "static");
Benchmark(() =>
{
var c1 = new Class1();
for (int i = 0; i < 1000000000; i++)
Class1.Sum3(c1, i, i + 2);
}, "static 2");
Benchmark(() =>
{
var c1 = new Class1();
for (int i = 0; i < 1000000000; i++)
c1.Sum(i, i + 2);
}, "instance");
Benchmark(() =>
{
for (int i = 0; i < 1000000000; i++)
Class1.Sum2(i, i + 2);
}, "static");
Benchmark(() =>
{
var c1 = new Class1();
for (int i = 0; i < 1000000000; i++)
Class1.Sum3(c1, i, i + 2);
}, "static 2");
Console.Read();
}
結果如下。
可以明顯看到static 2逐漸逼近instance,這裡還有幾個影響因素存在,就是在Debug模式下編譯器的最佳化選項是關閉的,另外Debug模式會插入許多除錯碼,這些除錯碼會對效能觀測產生不定時的影響,所以如果要觀測效能,打開最佳化、選擇Release模式比較精準。
要注意的是最佳化會抹除無用的程式碼,以本例來說,沒有對Sum的回傳值進行後續處理,因此編譯器最佳化時會視為此函示呼叫是不必要的,所以進行抹除,要避免這點只要加上NoInlining Attribute即可。
class Class1
{
[MethodImpl(MethodImplOptions.NoInlining)]
public int Sum(int x, int y) => x + y;
[MethodImpl(MethodImplOptions.NoInlining)]
public static int Sum2(int x, int y) => x + y;
[MethodImpl(MethodImplOptions.NoInlining)]
public static int Sum3(Class1 c, int x, int y) => x + y;
}
以下是結果。
如果你執行多次,記得每次都要Clean之後重新編譯才能得到較為客觀的結果,這是因為現代的CPU有著動態頻率的特性,加上指令碼預測,重複的動作很容易被快取,而動態頻率很容易影響結果,雖然都是數個CPU cycle的差距,但重複的動作會把這些影響放大,因此在這個例子上,觀測執行速度很不客觀,要完全客觀要從機器碼著手,先切回Debug模式,然後進行除錯並觀察機器碼。
可以明顯看到,靜態與非靜態的指令是有差別的,非靜態多傳了一個參數,也就是ecx這段,在Sum時ecx與edx就是12, 13,只傳兩個參數,Sum3很接近非靜態Sum,但還是有一個指令的差距(cmp dword....),這是因為當呼叫非靜態函式(Sum)時,會插入一段判斷該物件是否為null的指令碼,也就是cmp那段,但是這段在Release模式下會被抹除。
所以結論是在Release模式下,Sum1與Sum3所產生出來的呼叫指令碼是相等的。
除了靜態與非靜態兩種外,在OOP的世界中還有第三種函式,就是可覆載函式,常稱為虛擬函式,這種函式在編譯器的眼裡有不同的呈現方式,以下列程式來說。
class Class1
{
[MethodImpl(MethodImplOptions.NoInlining)]
public int Sum(int x, int y) => x + y;
[MethodImpl(MethodImplOptions.NoInlining)]
public static int Sum2(int x, int y) => x + y;
[MethodImpl(MethodImplOptions.NoInlining)]
public static int Sum3(Class1 c, int x, int y) => x + y;
[MethodImpl(MethodImplOptions.NoInlining)]
public virtual int SumVirtual(int x, int y) => x + y;
}
static void Main(string[] args)
{
var c1 = new Class1();
c1.Sum(12, 13);
c1.SumVirtual(12, 13);
Class1.Sum2(12, 13);
Class1.Sum3(c1, 12, 13);
Console.Read();
}
在機器碼層級如下。
編譯器在處理虛擬函式時,會編出為類別建立一個Virtual Method Table的機器碼,簡稱VMT,裡面每個元素都是一個函式位址,以此例來說,SumVirtual的機器碼會放在VMT裡(ecx),因此要呼叫時,必須由物件取得VMT然後取得真實函式位址後進行呼叫,這個VMT是跟著類別的,一個類別只有一個,所有物件都共用這組VMT,簡單點說,當成員函式是虛擬函式的狀態下,會比非虛擬函式多花上一個機器碼來進行呼叫,總結的效能比較如下所示。
靜態函式比非靜態呼叫指令碼有差距,產生效能影響這是事實,但是如果把原本的非靜態改成靜態,必然需要對內容作調整,例如把成員變數改成靜態,或是透過另一個靜態類別來儲存這些變數,這時會帶出兩個問題,一是改成靜態成員變數帶來的混亂,二是改成靜態類別又落入了間接存取的狀況,這通常會把省下的那一個指令時間加回來,正負得零,只是徒工。靜態與非靜態應該回歸設計,函式會設計成靜態通常是因為其沒有狀態,沒有side effect,這在.NET Framework中有很多例子可以參考,例如Math.Abs為何是靜態?因為他沒有狀態,同值呼叫會得到同樣的結果,所以沒有Side Effect,做為靜態很合理。String.ToString()為何是非靜態,因為她需要狀態,如果設計成靜態就變成了String.ToString(myString),這時靜態與非靜態就會等值(排除ToString其實是虛擬函式的這件事)。