C# 中利用執行時編譯實現泛函

oschina發表於2015-05-29

引言

我想要分享一個新模式,我開發來用於在 C# 中利用執行時編譯進行泛型計算。

過去的幾年裡我已經在程式設計並且看到許多在 C# 中實現泛型數學的例子,但是沒有一個能做得非常地好。在第一部分中我將一步步地解說我看到的一些例子,同時也會說明為什麼他們沒有向泛函提供好的模式。

我開發了這個模式以作為我的 Seven Framework 框架工程的一部分。如果你感興趣的話你可以點選:https://github.com/53V3N1X/SevenFramework。

問題概述

首先,根本的問題是在 C# 中處理泛型就像基類一樣。除了 System.Object 基類,他們都沒有隱式成員。也就是說,相對於標準數值型別(int,double,decimal,等)他們沒有算數運算子和隱式轉換。EXAMPLE 1 是一種理想情況,但是這段程式碼在標準 C# 中是不能編譯的。

// EXAMPLE 1 ---------------------------------------- 
namespace ConsoleApplication 
{   public class Program   
    {  public static void Main(string[] args)     
       {  Sum<int>(new int[] { 1, 2, 3, 4, 5 });     }     
    public static T Sum<T>(T[] array)     
     {  T sum = 0; // (1) cannot convert int to generic       
        for (int i = 0; i < array.Length; i++)         
        sum += array[i]; // (2) cannot assume addition operator on generic       
        return sum;     }   
               } 
}

確實如此,EXAMPLE 1 不能通過編譯因為:

  1. 型別”T”不能確保int數值的隱式轉換。
  2. 型別”T”不能確保一個加法操作。

現在我們瞭解了根本問題後,讓我們開始尋找一些方法克服它。

介面化解決方法

C#中的 where 子句是一種強迫泛型滿足某種型別的約束。然而,用這種方法就要求有一種不存在於C#中的基本數值型別。C#有這樣一種基本數值型別是最接近強制泛型成為數值型別的可能了,但這並不能在數學上幫助我們。EXAMPLE 2 仍然不能夠通過編譯,但如果我們創造出了我們自己的基本數值型別,這將成為可能。

Hide Copy Code

// EXAMPLE 2 ---------------------------------------- 
namespace ConsoleApplication {   
public class Program   
{     
public static void Main(string[] args)    
{       
Sum<int>(new int[] { 1, 2, 3, 4, 5 });     
}     
public static T Sum<T>(T[] array)       
where T : number  // (1) there is no base "number" type in C#     
{       T sum = 0;       
for (int i = 0; i < array.Length; i++)         
sum += array[i];       
return sum;     }   
} 
}

現在 EXAMPLE 2 還不能編譯因為:

  1. 在C#中沒有基本“數值”型別。

如果我們實現了我們自己的基本“數值”型別,就可以讓它通過編譯。我們所必需做的就是迫使這個數值型別擁有C#基本數值型別一般的算數運算子和隱式轉換。邏輯上來講,這應該是一個介面。

然而,即使我們自己做數值介面,我們仍然有一個重大問題。我們將不能夠對 C# 中的基本型別做通用數學計算,因為我們不能改變 int,double,decimal 等的原始碼來實現我們的介面。所以,我們不僅必須編寫自己的基本介面,還需要為C#中的原始型別編寫包裝器。
在例3中,我們有我們自己的數值介面,“數字”,和原始型別int的包裝器,Integer32。

// EXAMPLE 3 ---------------------------------------- 
namespace ConsoleApplication 
{   
public class Program   
{     
public static void Main(string[] args)     
{       
Sum(new Number[]       
{         
new Integer32(1), // (1) initialization nightmares...         
new Integer32(2),          
new Integer32(3),         
new Integer32(4),         
new Integer32(5)       
});    
 }     
public static Number Sum(Number[] array)     
{       
Number sum = array[0].GetZero(); // (2) instance-based factory methods are terrible design      
for (int i = 0; i < array.Length; i++)         
sum = sum.Add(array[i]);       
return sum;     
}  
 }   
public interface Number   
{     
Number GetZero(); // (2) again... instance based factory methods are awful     
Number Add(Number other);   
}   
public struct Integer32 : Number // (3) C# primitives cannot implement "Number"   
{     
int _value;     
public Integer32(int value)     
{       
this._value = value;     
}     
Number Number.GetZero()     
{       
return new Integer32(0);     
}     // (4) you will have to re-write these functions for every single type      
Number Number.Add(Number other)     
{       
return new Integer32(_value + ((Integer32)other)._value);     
}   
} 
} // (5) this code is incredibly slow

好的,這樣 EXAMPLE 3 就編譯了,但是它有點糟,為什麼呢:

  1. 程式設計時用介面初始化變數是非常醜陋的。
  2. 你不該用工廠方法或建構函式作為一個例項化方法,因為它是一種糟糕的設計並且很容易在程式各處造成空引用異常。
  3. 你不能讓C#基本型別去實現“Number”介面所以只能使用自定義型別工作。
  4. 它不是泛函因為你必須每一步都寫一個自定義的實現。
  5. 這段程式碼因封裝了基本型別工作極其慢。

如果你的泛函庫不能使在 C# 中完成泛型數學運算,沒有人會對此買單。因此,接下里讓我們處理這個問題。如果不能夠修改 C# 原始資料型別去實現想要的介面,那麼我們就創造另一種型別能夠處理那些型別具有的所有數學運算。這就是在 .Net 框架中廣泛使用的標準提供者模式。

邊注/發洩:就我個人來說,我憎恨提供者模式。但我至今沒有發現使用委託有處理不好的例子。當大量建立大量提供者時,他們沒有使用委託。

當我們使用提供者模式,本質上仍是做和以前同樣的事,但一個提供者類就能處理所有的數學運算。在EXAMPLE 4中檢驗它:

/EXAMPLE 4 ---------------------------------------- 
namespace ConsoleApplication 
{   
public class Program   
{     
public static void Main(string[] args)     
{      
Sum<int>(new int[] { 1, 2, 3, 4, 5}, new MathProvider_int());     
}     // (1) all the methods need access to the provider     
public static T 
Sum<T>(T[] array, MathProvider<T> mathProvider)     
{       
T sum = mathProvider.GetZero();       
for (int i = 0; i < array.Length; i++)         
sum = mathProvider.Add(sum, array[i]);       
return sum;     }   

public interface MathProvider<T>   
{     
T GetZero(); // (2) you still need instance factory methods     
T Add(T left, T right);   
}  
 public class MathProvider_int : MathProvider<int>   
{     
public MathProvider_int() { }     
int MathProvider<int>.GetZero()     
{       
return 0;     
}     // (3) you still have to implement each function for every single type      
int MathProvider<int>.Add(int left, int right)     
{       
return left + right;     
}   
} 
} // (4) can be slow depending on implementation (this version is slow)

EXAMPLE 4 通過把所有的泛函性質移動到幫助類中,我們可以使用C#基本型別執行數學運算。然而,這僅僅修復 EXMAPLE 3 中的第一個問題。我們仍舊需要解決以下問題:

  1. 所有方法都必須訪問 mathProvider 類。雖然您可以編寫程式碼,讓其不必在每個函式間傳遞,這個原則同樣適用於其它類似的結構。
  2. 你的例項化仍然基於工廠方法。在上面的情況中它是一個來自於int的轉換。
  3. 在原始程式碼中你仍然需要為每一個簡單的型別中實現泛函性。
  4. 這仍然相當慢,除非你為 provider 做一些”聰明的“快取。provider 的傳遞和查詢加起來真的很多。

現在我們已經嘗試過在數值型別本身(EXAMPLE 3)和外部 provider(EXAMPLE 4)上使用介面。使用介面我們已經不能做更多了。可以確定的是我們可以運用一些聰明巧妙的儲存方法,但最終仍會面臨相同的問題:必須在每一步都支援定製的實現。

最後說一句…在 C# 中介面不適合用在高效的泛函計算中。

物件轉換

在 C# 中所有事物都可以轉換成 System.Object 型別。因此,我只要把每一個事物轉換成一個物件然後用控制流處理它就可以了。讓我們試一試。

// EXAMPLE 5 ---------------------------------------- 
namespace ConsoleApplication 
{   
public class Program  
{     
public static void Main(string[] args)     
{       
MathProvider<int>.Sum(new int[] { 1, 2, 3, 4, 5});    
}   
}   
public static class MathProvider<T>  
 {     
public static T Sum(T[] array)     
{       // (1) still requires a custom implementation for every type       
if (typeof(T) == typeof(int))       
{         
T sum = (T)(object)0; // (2) largescale casting is very glitch prone         
for (int i = 0; i < array.Length; i++)           
sum = (T)(object)((int)(object)sum + ((int)(object)array[i]));         
return sum;       }       t
hrow new System.Exception("UNSUPPORTED TYPE"); // (3) runtime errors     
}   } } 
// (4) horribly slow...

事實是,這看起來要比介面化方法好。程式碼很簡單並且容易使用。然而,和以前一樣我們還是有很多問題:

  1. 我們仍要為每一種型別建立一個定製的實現。
  2. 同時我們有大量的型別轉換可能造成異常而且很慢。
  3. 以及對不支援的型別有執行時錯誤。
  4. 效能低下。

注:我不知道他們是否仍然在用 F# 來做這個,但是當我瀏覽 F# 中一般的標準數學函式時,他們所做的看起來像是最低水平的物件轉換。可笑至極!

物件轉換是另一個死衚衕,但它至少非常簡單易用。

代理

代理……真的很棒!

我們不能像使用原始型別那樣高效地完成數學運算。如果沒有每種繼承關係編譯器將不能作出判斷,並且我們也不能讓C#的原始型別繼承我們自己的類。

所以,我們把一般類外的一般程式碼的功能都移除吧。我們需要怎麼做呢?代理!只要在一般的類裡設定一個代理,並在執行時從外部分配,這些型別就能夠被編譯器所識別。

然而,我們可以把委託(代理)放到泛型類中,在外部編譯委託(代理)然後在執行時分配。

// EXAMPLE 5 ---------------------------------------- 
namespace ConsoleApplication 
{   
public class Program   
{     
public static void Main(string[] args)     
{       // (1) requires a constructor method to be called prior to use       
MathProviderConstructors.Construct();       
MathProvider<int>.Sum(new int[] { 1, 2, 3, 4, 5});    
 }   }   
public static class MathProviderConstructors   
{     
public static void Construct()     
{       
// (2) still requires a custom implementation for every type       
MathProvider<int>.Sum = (int[] array) =>      
 {         
int sum = 0;         
for (int i = 0; i < array.Length; i++)           
sum = sum + array[i];         
return sum;       
};     }   }   
public static class MathProvider<T>   
{     
// (3) still have runtime errors for non-implemented types (null-ref)     
public static System.Func<T[], T> Sum;   
} }

EXMAPLE 5 是目前最好的泛函例子。它執行很快,適用於任何型別,並且除了要確保靜態構造方法必須被呼叫外很容易使用。但是,它仍有一些瑕疵…(1)構造方法必須被呼叫,(2)對每一個型別依舊須要自定義一個實現,(3)還會丟擲執行時錯誤。

在這一點上我們必須做出一些妥協。首先,執行時異常時幾乎不可避免的。我能想到的唯一方法是製作一個自定義外掛加入到 Visual Studio 中那將會丟擲額外的編譯錯誤。那為了這篇文章的目的,就必須處理這個執行時異常。然而最大的問題是我們還是要為我們需要支援的每一個給型別寫一個函式。一定會有解決這個問題的辦法!

程式碼

這是我目前的一個通用的數學模式的版本:

using Microsoft.CSharp; using System; 
using System.CodeDom.Compiler; 
using System.Reflection; 
namespace RuntimeCodeCompiling {   
public static class Program   
{     
public static Action action;     
public static void Main(string[] args)     
{       
Console.WriteLine("Sum(double): " + Generic_Math<double>.Sum(new double[] { 1, 2, 3, 4, 5 }));
Console.WriteLine("Sum(int): " + Generic_Math<int>.Sum(new int[] { 1, 2, 3, 4, 5 }));       
Console.WriteLine("Sum(decimal): " + Generic_Math<decimal>.Sum(new decimal[] { 1, 2, 3, 4, 5 }));       
Console.ReadLine();     
}     
#region Generic Math Library Example     
public static class Generic_Math<T>     
{       
public static Func<T[], T> Sum = (T[] array) =>       
{ // This implementation will make this string be stored in memory during runtime, 
//so it might be better to read it from a file         
string code = "(System.Func<NUMBER[], NUMBER>)((NUMBER[] array) => 
{ NUMBER sum = 0; for (int i = 0; i < array.Length; i++) sum += array[i]; return sum; })";         
// This requires that "T" has an implicit converter from int values and a "+" operator         
code = code.Replace("NUMBER", typeof(T).ToString());         
// This small of an example requires no namspaces or references         
Generic_Math<T>.Sum = Generate.Object<Func<T[], T>>(new string[] { }, new string[] { }, code); 
return Generic_Math<T>.Sum(array);       
};     
}     
/// <summary>Generates objects at runtime.</summary>     
internal static class Generate    
 {
/// <summary>Generates a generic object at runtime.</summary>       
/// <typeparam name="T">The type of the generic object to create.</typeparam>      
/// <param name="references">The required assembly references.</param>       
/// <param name="name_spaces">The required namespaces.</param>       
/// <param name="code">The object to generate.</param>       
/// <returns>The generated object.</returns>       
internal static T Object<T>(string[] references, string[] name_spaces, string code)       
{
string full_code = string.Empty;         
if (name_spaces != null)           
for (int i = 0; i < name_spaces.Length; i++)             
full_code += "using " + name_spaces[i] + ";";         
full_code += "namespace Seven.Generated 
{";         
full_code += "public class Generator 
{";         
full_code += "public static object Generate() 
{ return " + code + "; } } }";         
CompilerParameters parameters = new CompilerParameters();         
foreach (string reference in references)           
parameters.ReferencedAssemblies.Add(reference);        
 parameters.GenerateInMemory = true;         
CompilerResults results = new CSharpCodeProvider().CompileAssemblyFromSource(parameters, full_code);         
if (results.Errors.HasErrors)         
{           
string error = string.Empty;           
foreach (CompilerError compiler_error in results.Errors)             
error += compiler_error.ErrorText.ToString() + "/n";           
throw new Exception(error);         
}         
MethodInfo generate = results.CompiledAssembly.GetType("Seven.Generated.Generator").GetMethod("Generate");         
return (T)generate.Invoke(null, null);       
}     
}    
#endregion   
} }

程式碼工作原理:

如果通用數學程式碼儲存為一個字串,可以使用 string hacking(又名macros aka string replace),以改變執行時的程式碼。我們可以寫一個函式,然後在使用該函式時改變該函式的型別。因此,我們可以認定泛型有必須的數學操作符來實現該函式。

在第一次呼叫函泛時,它會構建自己和重新自動分配。這樣,我們就不必處理一個愚蠢的建構函式,它只須要根據我們所需構造函即可。

據我所知,你不能編譯使用執行時編譯器編譯單個物件,我只是編譯了一個返回我需要的值型別的方法。可能存在替代方法,尤其是當你使用序列化技術的時候,但是我不是很熟悉學歷惡化格式,所以這種方法對我來說可能更容易。
優點:

  1. 每一種型別只需要程式碼的一個版本。
  2. 有沒有構造方法或設定方法呼叫,方法會像我們所希望的那樣自我構造。
  3. 快!這種方法據我所知唯一的開銷呼叫委託的開銷。

小缺點:(這些“缺點”可以克服)
1.它可以是惱人的編寫通用的數學函式作為一個字串。 解決辦法:我建議在單獨的檔案編寫通用程式碼並解析。這樣的字串不是永久被儲存在記憶體,你仍然可以編輯它,就像在 Visual Studio 中使用標準的C#一樣。

2.這不是一個跨平臺的例子。補丁:它很容易實現跨平臺功能。根據他們的網站所述,這像一個包含反射和執行時編譯庫的 Mono 專案。因此只要動態查詢執行時編譯器就能讓“生成的”功能類跨平臺。

3.如果泛型的“typeof(T).ToString()”被嵌入到泛型中,現在的這些程式碼將會崩潰。補丁:使用某種型別建立一個函式 再建立一個適當的字串來表示這種型別達到和原始程式碼一樣的目的。

4.我們還是有編譯錯誤。告訴我們有自定義型別”struct Fraction128“忘記過載”+“運算子。同時也會丟擲執行時錯誤。補丁:這個問題可以通過在編譯時寫一個 VS 外掛去檢測泛函運算中使用的型別是否包含基本數值操作符而被修復。我只是把這些問題指出來告訴你們它是可修復的,我不會去做這些。到用的時候,不要幹蠢事,:P

結論

通過使用執行時編譯,您可以將存在數學運算子和值型別轉換的假設強制認定為成立,使你能夠做一些真正意義生的數學計算。它是快速的,相對於本文中介紹的其他方法是易於維護的,而且非常強大。

雖然這不是全部…我心中已經有了一些改進的想法。如果您有任何建議,我很願意虛心聆聽。謝謝!

版權

本文及相關原始碼及檔案屬於 The Code Project Open License (CPOL)

相關文章