Python 是當今火紅的程式語言,為 AI/Mechine Learning 領域的奧林匹克指定開發語言,在這些領域,你得說 Python 才能享有一等國民的待遇。

身為 C# 已經寫到得心應手的老人,若在 Python 場子遇到刁鑽需求,但用 C# 可以秒殺或已有現成程式庫,此時我有三種選擇:

  1. 尋找對應的 Python 程式庫或範例
    閃開讓專家來是最上策,但有時看緣分,踏破鐵鞋也未有所得
  2. 用 C# 寫好叫 ChatGPT 轉成 Python
    成功率視狀況,看語法是否單純,若涉及 .NET 獨有 API,轉換得費番手腳。但不依賴額外程式庫讓架構簡潔單純,為次佳解
  3. 用 C# 寫程式庫給 Python 呼叫
    也是個選擇。好處是可直接沿用 C# 成果不必費心移植,編成一顆 .dll (Windows)/.dylib (macOS)/.so (Linux) 給 Python 參考即可

今天這篇就來研究第 3 種做法。

這個技術叫 Native AOT,是 .NET 7 正式加入的功能(更早之前為實驗版,7.0 正式移入 core/runtime,8.0 功能更完整),概念是將 .NET 程式編成原生二進位檔,讓執行檔又小(不用 Runtime)又快(不需 JIT 編譯)。

但 NativeAOT 使用上也有一些限制,像是不支援 Assembly.LoadFile、System.Reflection.Emit、C++/CLI、Built-in COM... 等。參考

補充:MVP Poy 有場 NativeAOT 線上講座,推薦給想深入了解 NativeAOT 來龍去脈的同學。

我打算把 阿拉伯數字與中文數字雙向轉換 .NET 函式包成程式庫給 Python 呼叫,體驗用原生程式庫跨平台跨語言整合。

將 .NET 專案寫成 Native AOT 程式庫供 Unmanaged 世界呼叫的做法我主要參考這篇:Building Native Libraries with NativeAOT

完整專案已上傳 Github,這裡只簡要說明重點:

  1. dotnet new classlib -o chtnumconv-naot 建立專案
  2. 修改 .csproj 加上 <PublishAot>true</PublishAot>
  3. 將 Class1.cs 換成 ChtNumConverter.cs
  4. dotnet publish /p:NativeLib=Shared --use-current-runtime 編譯 (註:Windows 要安裝 VS2022 Desktop Development with C++,參考)
    若為 Windows 平台,在 bin\Release\net8.0\win-x64\public 下會有 chtnumconv-naot.dll 及 .pdb,即為 Native AOT 版本的原生二進位程式庫

    大小僅 1.9MB
  5. NativeLib 參數有 Shared 與 Static 兩種,Static 的話,程式庫檔案會在編譯時合併入,包含在應用程式執行檔裡,此時的輸出格式是 .lib (Windows) / .a (Linux/macOS);Shared 的話,程式庫檔為 .dll (Windows) / .so (Linux) / .dylib (macOS),可供多個應用程式共用,並以動態方式載入。如果要給 Python 用,只能選 Shared。參考

ChtNumConvert.cs 的改寫重點如下:

public class ChtNumConverter
{

    // 解析中文數字       
    [UnmanagedCallersOnly(EntryPoint = "parse_cht_num")] 
    public static long ParseChtNum(IntPtr chtNumStringPtr)
    {
        string chtNumString = Marshal.PtrToStringUTF8(chtNumStringPtr)!;
        var isNegative = false;
        /* 略 */
        num += Parse4Digits(chtNumString);
        return isNegative ? -num : num;
    }
    // 轉換為中文數字
    [UnmanagedCallersOnly(EntryPoint = "to_cht_num")]
    public static IntPtr ToChtNum(long n)
    {
        var negtive = n < 0;
        t = Regex.Replace(t, "^一十", "十");
        /* 略 */
        var result = (negtive ? "負" : string.Empty) + t;
        // TODO 研究傳回 UTF-8 的方法
        return Marshal.StringToHGlobalAnsi(result);
    }
    // 釋放記憶體
    [UnmanagedCallersOnly(EntryPoint = "free_mem")]
    public static void FreeMem(IntPtr ptr) => Marshal.FreeHGlobal(ptr);
}

ParseChtNum() 及 ToChtNum() 加上 [UnmanagedCallersOnly(EntryPoint = "method-name")],string 參數型別或傳回型別改為 IntPtr,用 Marshal.PtrToStringUTF8(chtNumStringPtr) 轉成字串,用 Marshal.StringToHGlobalAnsi(result) 將字串傳成 IntPtr,由於 StringToHGlobalAnsi() 會配置 Unmanaged 記憶體,無法由 GC 機制回收,我看範例程式不太有 人處理這個問題,但我還是加了一個 FreeMem(IntPtr ptr) 讓 Python 呼叫以釋放配置的記憶體。(或是有其他更正確的做法,歡迎指正補充)

Marshal.StringToHGlobalAnsi() 會傳回 ANSI/BIG-5 編碼中文,我沒試出傳回 UTF-8 或 Unicode 編碼的好方法,歡迎知道的朋友補充。

再來看 Python 端,我先研究了 Python 呼叫 C/C++ 程式庫的做法,找到這篇:Python c/c++ 整合

簡單歸納 Python 引用 C/C++ 程式庫常用的方案:Python/C API、ctypes、cffi、pybind11、Cython

  1. Python/C API:維護成本高、相容性差,不推薦直接使用
  2. ctypes:Python 內建,不需 Wrapper,不支援 C++
  3. cffi:依賴額外程式庫,不需 Wrapper,不支援 C++
  4. pybind11:特色是支援 C++,優點對 .NET 無效
  5. Cython:把 Python 程式碼編譯成 C 程式碼,等於不是寫 Python 換了新語言,要換 Toolchain 異動偏大

評估後決定用 ctypes。

import ctypes
chtnumconverter = ctypes.cdll.LoadLibrary("X:/Github/chtnumconv-naot/bin/Release/net8.0/win-x64/publish/chtnumconv-naot.dll")
s = '一千零二十四'
n = chtnumconverter.parse_cht_num(s.encode('utf-8'))
print(n)
chtnumconverter.to_cht_num.restype = ctypes.c_char_p
p = chtnumconverter.to_cht_num(65536) # p = pointer to string
# TODO 找出回傳 UTF-8 編碼的方法
print(p.decode('big5'))
chtnumconverter.free_mem(p); # free memory

我最後拼湊出的 Python 程式如上,似懂非懂,但執行成功了!

這是人類的一小步(謎之音:別自我膨脹,頂多是咬冷笱抖一下),卻是我的一大步。

A exmaple to build a .NET Native AOT library for Python.


Comments

# by BEN

NAOT的DLL,還可以被c#的專案使用嗎?

# by Jeffrey

to BEN, 可以,但要視同 C/C++ 的 Unamanged Library 用 DllImport 方式引用。

# by Aaron

"用 dotnet net classlib -o chtnumconv-naot 建立專案" 開頭應為 dotnet "new"

# by Jeffrey

to Aaron, 已更正,謝~

Post a comment