靜態函式的兩三事

每隔一段時間,偶而就會聽到一些靜態函式的都市傳說,比較誇張點的是非靜態函式(也就是類別成員函式),會依據物件而複製,所以占用記憶體較多。較為貼近合理情況的是靜態函式的執行速度優於非靜態函式,如果你相信我的話,以下就是答案。

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其實是虛擬函式的這件事)。