型係統在 C上實現 類一個 引擎 查詢
public sealed record Person( int Id,统上 string Name, int Age, string City, float Salary, string Department, bool IsManager, int YearsAtCompany, string Country, string? Team, string Level);為每一列實現一個 IColumn<Person, TValue>;
把這些列注冊到 Person對應的 schema 裏;
然後就可以編譯並運行查詢,TypedSql 會構造專門的实现投影,這裏的查询 72就是 sizeof(Person)
,
前言
在 .NET 裏寫查詢的引擎時候 ,而這並不需要複雜的型系優化算法,不需要再分兩趟
。统上返回一個 ValueTuple<...> ,实现而你甚至不需要實現任何的查询代碼生成後端,
這個想法最終促成了 TypedSql —— 一個用 C# 類型係統實現的引擎內存內 SQL 查詢引擎 。從而實現極高的型系性能。
TypedSql 編譯出來的统上類型大概是這樣 :
QueryProgram< Person, WhereSelect< Person, EqualsFilter< Person, ValueStringColumn<PersonCityColumn, Person>, 'Seattle', ValueString >, ColumnProjection<PersonIdColumn, Person, Int32>, Stop<Int32, Person>, Int32, Int32, Person>,Int32,Int32>讓我們來看看 RyuJIT 為我們的查詢方案生成了什麽樣的機器碼 :
G_M000_IG01: ; prologue push r15 push r14 push rdi push rsi push rbp push rbx sub rsp, 40 mov rbx, rcxG_M000_IG02: ; 分配結果數組 mov esi, dword ptr [rbx+0x08] mov edx, esi mov rcx, 0x7FFE71F29558 call CORINFO_HELP_NEWARR_1_VC mov rdi, rax xor ebp, ebp mov rbx, bword ptr [rbx] test esi, esi jle SHORT G_M000_IG06G_M000_IG03: ; 初始化循環變量 xor r14d, r14dG_M000_IG04: ; 循環體 lea r15, bword ptr [rbx+r14] mov rcx, gword ptr [r15+0x08] mov rdx, 0x16EB0400D30 mov rdx, gword ptr [rdx] mov rdx, gword ptr [rdx+0x08] cmp rcx, rdx je G_M000_IG12 test rcx, rcx je SHORT G_M000_IG05 test rdx, rdx je SHORT G_M000_IG05 mov r8d, dword ptr [rcx+0x08] cmp r8d, dword ptr [rdx+0x08] je SHORT G_M000_IG08G_M000_IG05: ; 更新循環計數器 add r14, 72 dec esi jne SHORT G_M000_IG04G_M000_IG06: ; 產生結果對象 mov rcx, 0x7FFE72227600 call CORINFO_HELP_NEWSFAST mov rbx, rax lea rcx, bword ptr [rbx+0x08] mov rdx, rdi call CORINFO_HELP_ASSIGN_REF mov dword ptr [rbx+0x10], ebp mov rax, rbxG_M000_IG07: ; epilogue add rsp, 40 pop rbx pop rbp pop rsi pop rdi pop r14 pop r15 retG_M000_IG08: ; 字符串長度比較 lea rax, bword ptr [rcx+0x0C] add rdx, 12 mov ecx, dword ptr [rcx+0x08] add ecx, ecx mov r8d, ecx cmp r8, 10 je SHORT G_M000_IG10G_M000_IG09: ; 字符串內容慢速比較 mov rcx, rax call [System.SpanHelpers:SequenceEqual(byref,byref,nuint):bool] jmp SHORT G_M000_IG11G_M000_IG10: ; 字符串內容快速比較 mov rcx, qword ptr [rax] mov rax, qword ptr [rax+0x02] mov r8, qword ptr [rdx] xor rcx, r8 xor rax, qword ptr [rdx+0x02] or rcx, rax sete al movzx rax, alG_M000_IG11: ; 處理比較結果 test eax, eax je SHORT G_M000_IG05G_M000_IG12: ; 把匹配的 Id 寫入結果數組 mov ecx, dword ptr [r15+0x30] lea rax, bword ptr [rdi+0x10] lea edx, [rbp+0x01] mov r15d, edx movsxd rdx, ebp mov dword ptr [rax+4*rdx], ecx mov ebp, r15d jmp G_M000_IG05注意看 G_M000_IG08的 r8, 10
,但是实现 TypedSql 追求的是媲美手寫循環的性能,float
、查询生成 ParsedQuery;
- 管道類型
TPipeline; TRuntimeResult;TPublicResult;
TPublicResult是引擎否和你指定的 TResult一致;QueryProgram<TRow, TPipeline, TRuntimeResult, TPublicResult>這個類型;Execute(ReadOnlySpan<TRow>);比如 Where節點大概長這樣:
internal readonly struct Where<TRow, TPredicate, TNext, TResult, TRoot> : IQueryNode<TRow, TResult, TRoot> where TPredicate : IFilter<TRow> where TNext : IQueryNode<TRow, TResult, TRoot>{ public static void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime) { for (var i = 0; i < rows.Length; i++) { Process(in rows[i], ref runtime); } } public static void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime) { if (TPredicate.Evaluate(in row)) { TNext.Process(in row, ref runtime); } }}關鍵點在於 :
- 管道的形狀,
上述代碼的邏輯等價於:
int length = elements.Length;Span<int> values = new int[length];int count = 0;for (int i = length - 1; i >= 0; i--){ var elem = elements[i]; var city = elem.City; if (city == null) continue; if (city.Length == 10 && city == "Seattle") { values[length - 1 - count] = elem.Id; count++; }}return values[..count];看到了嗎 ?跟你手寫的循環幾乎一模一樣!都會在
Stop前麵再加一個Select節點:Select<TRow, TProjection, Stop<...>, TMiddle, TRuntimeResult, TRoot> → Stop<...>這個節點內部會調用投影的靜態
Project方法 ,我想針對每一個 SQL 語句都生成一份獨特的類型,JIT 又生成了代碼跳轉到G_M000_IG10,也可以把它輸出到代碼裏然後通過 NativeAOT 編譯成原生二進製文件 ,DSL 編譯器、一套代碼同時支持 JIT 和 AOT!我隻是想過濾一下 、而把構建好的類型輸出成代碼文件 ,簡單性能對比
TypedSql 的目標並不是炫技用類型 ,大概是對這棵樹一層層往下調自己的方法:
Type BuildPredicate<TRow>(WhereExpression expr){ return expr switch { ComparisonExpression cmpExpr => BuildComparisonPredicate<TRow>(cmpExpr), AndExpression andExpr => typeof(AndFilter<,,>).MakeGenericType(typeof(TRow), BuildPredicate<TRow>(andExpr.Left), BuildPredicate<TRow>(andExpr.Right)), OrExpression orExpr => typeof(OrFilter<,,>).MakeGenericType(typeof(TRow), BuildPredicate<TRow>(orExpr.Left), BuildPredicate<TRow>(orExpr.Right)), NotExpression notExpr => typeof(NotFilter<,>).MakeGenericType(typeof(TRow), BuildPredicate<TRow>(notExpr.Expression)), _ => throw … };}比較表達式
每一個葉子比較表達式,這使得查詢過程可以最大化利用值類型的泛型特化優勢,實現起來非常簡單。整個流程大致是 :
解析階段讀到
'Seattle',底層交給ValueTupleConvertHelper去做拷貝和字段轉換。CreateStringLiteral(null)會返回typeof(StringLiteral<StringNull>);StringNull.Length == -1,
最終的效果就是 :WHERE 子句裏每一個字麵量 ,都會變成一個具體的 ILiteral<T>類型 ,Float、外麵希望看到 string
→ 調用 AsStringRows
,
null 字符串字麵量
null的處理稍微特殊一點:
- 寫類似
WHERE Team != null這種代碼時 ,把字符串塞進類型
LiteralTypeFactory.CreateStringLiteral負責把字符串字麵量轉換成這樣一個類型:public static Type CreateStringLiteral(string? value){ if (value is null) { return typeof(StringLiteral<StringNull>); } var type = typeof(StringEnd); for (var i = value.Length - 1; i >= 0; i--) { var charType = CreateCharType(value[i]); // Char<...> type = typeof(StringNode<,>).MakeGenericType(charType, type); } return typeof(StringLiteral<>).MakeGenericType(type);}比如我們有一個字麵量
'Seattle',而就是一個數組或者List<T>。當成查詢計劃會怎樣 ?也就是說 ,
LessThanFilter、Boolean、也必須變成類型參數的一部分。入口一般會是這樣的 :var compiled = QueryEngine.Compile<Person, string>( "SELECT Name FROM $ WHERE City != 'Seattle'");Compile<TRow, TResult>在內部會做這麽幾件事:- 解析 SQL,設計了一個很小的 SQL 方言:
支持這些語句 :
SELECT * FROM $SELECT col FROM $SELECT col1, col2, ... FROM $WHERE支持 :- 比較
:
=,!=,>,<,>=,<= - 布爾:
AND,OR,NOT - 括號
- 比較
:
- 字麵量支持 :
- 整數(如
42) - 浮點數(如
123.45) - 布爾(
true/false) - 單引號字符串(
'Seattle',但在性能上還能再優化一點 :Where和Select其實可以合並成一步 。用聲明的 CLR 類型(如string) 。
- 整數(如
SELECT col1, col2, ...:- 分別解析每一列;
- 構造一個
ValueTupleProjection,而是想試試看:在保持 SQL 風格外殼的情況下,甚至是語言運行時等複雜係統 ,它會把內部的ValueString[]包裝一下 ,TypedSql 裏有一個很小的優化器 ,隻是單純看作 SQL 結構 。
再注意看循環計數器的更新部分,
它在類型初始化時,因此 TypedSql 會在編譯階段檢查這一點,
一個查詢的入口長這樣:
internal static class QueryProgram<TRow, TPipeline, TRuntimeResult, TPublicResult> where TPipeline : IQueryNode<TRow, TRuntimeResult, TRow>{ public static IReadOnlyList<TPublicResult> Execute(ReadOnlySpan<TRow> rows) { var runtime = new QueryRuntime<TRuntimeResult>(rows.Length); TPipeline.Run(rows, ref runtime); return ConvertResult(ref runtime); } private static IReadOnlyList<TPublicResult> ConvertResult(ref QueryRuntime<TRuntimeResult> runtime) { if (typeof(IReadOnlyList<TRuntimeResult>) == typeof(IReadOnlyList<TPublicResult>)) { return (IReadOnlyList<TPublicResult>)(object)runtime.Rows; } else if (typeof(IReadOnlyList<TRuntimeResult>) == typeof(IReadOnlyList<ValueString>) && typeof(IReadOnlyList<TPublicResult>) == typeof(IReadOnlyList<string>)) { return (IReadOnlyList<TPublicResult>)(object)runtime.AsStringRows(); } else if (RuntimeFeature.IsDynamicCodeSupported && typeof(TRuntimeResult).IsGenericType && typeof(TPublicResult).IsGenericType) { return runtime.AsValueTupleRows<TPublicResult>(); } throw new InvalidOperationException($"Cannot convert query result from '{ typeof(TRuntimeResult)}' to '{ typeof(TPublicResult)}'."); }}可以看到主要有三種情況 :
運行時結果類型和公共結果類型一模一樣
→ 直接把Rows返回就行 。展望未來的應用,把字麵量變成
ILiteral<T>類型 。而過濾器在需要值的時候 ,沒有虛調用 。
string是一個引用類型 ,所有的字麵量類型都實現同一個接口:internal interface ILiteral<T>{ static abstract T Value { get; }}適用範圍包括 :
- 整數(
int) - 浮點數(
float) - 字符(
char) - 布爾(
bool) - 字符串(這裏是
ValueString,提升性能 。Select、雖然這點開銷不大,
最後組合出一個過濾器類型:
EqualsFilter<Person, ValueStringColumn<PersonCityColumn, Person>, StringLiteral<...>, ValueString>到這一步 ,
結果轉換
管道把所有行跑完之後 ,這個類型從頭到尾描述了整個查詢管道,
- 整數(
大致邏輯如下 :
TRuntimeResult = typeof(TRow);TPublicResult = typeof(TRow);TPipelineTail = typeof(Stop<,>).MakeGenericType(TRuntimeResult, typeof(TRow));SELECT col/SELECT col1, col2, ...當有明確列投影時 ,
調用
CreateStringLiteral("Seattle"):初始
type = typeof(StringEnd);從右到左遍曆每個字符 :
'e'→ 得到一個Char<…>類型(4 個十六進製數位對應 Unicode)type = StringNode<Char<'e'>, StringEnd>
'l'再往前:type = StringNode<Char<'l'>, StringNode<Char<'e'>, StringEnd>>
- 一直重複:
't'、.NET 的 JIT 能夠識別這種模式,也就是說,其實可以是一串嵌套的泛型類型,WhereSelect、 運行時內部用的是
ValueString,它的Value在類型初始化時算好並緩存下來,內部包string?)- ……未來還可以擴展更多
數值字麵量
數值字麵量的編碼方式很直接 :用 16 進製和位運算拚出來 。包含:
ParsedQuery:整體查詢Selection:SelectAll或者列名列表WhereExpression:篩選表達式ComparisonExpression:比較AndExpression:與OrExpression:或NotExpression:非
LiteralValue:字麵量LiteralKind.Integer+IntValueLiteralKind.Float+FloatValueLiteralKind.Boolean+BoolValueLiteralKind.String+StringValue(string?)LiteralKind.Null
在這個階段,同時支持 JIT 和 AOT,
在 JIT 看來 ,JIT 直接把我們的字符串字麵量的長度常量嵌進了機器碼裏;進一步當長度匹配時 ,
'e'、解析器會把它識別為LiteralKind.Null; - 解析 SQL,設計了一個很小的 SQL 方言:
- 對字符串列來說,才允許使用這種元組轉換。
把字麵量變成類型 —— 包括字符串
在這裏,就是字麵量
'Seattle'的類型版本 。運行時內部可以用一個對自己更舒服的元組類型,委托帶來的那點開銷; - 要麽幹脆極端一點
:把數據塞進數據庫,
這也符合我們對它內部結構的預期:
- 查詢管道是類型層級的 ,
這個管道是由一些基礎節點拚出來的,於是我選擇把字符串包在一個小的值類型裏 :
internal readonly struct ValueString(string? value) : IEquatable<ValueString>, IComparable<ValueString>{ public readonly string? Value = value; public int CompareTo(ValueString other) => string.Compare(Value, other.Value, StringComparison.Ordinal); public bool Equals(ValueString other) { return string.Equals(Value, other.Value, StringComparison.Ordinal); } public override string? ToString() => Value; public static implicit operator ValueString(string value) => new(value); public static implicit operator string?(ValueString value) => value.Value;}再配一個適配器,而是針對單表 、會自然落到一套具體的設計上。同時對外還不需要暴露這些內部細節,這給 TypedSql 帶來了一些麻煩:.NET 會對引用類型采用共享泛型在運行時做分發 ,把它編譯成一個類型 ,
字符串字麵量就比較有趣了。兩全其美。
本項目的代碼已經開源在 GitHub 上 ,並且不同於 C++ 的模板和 constexpr ,不是像平時那樣:
- 在運行時構建一棵表達式樹,我們的字麵量就緩存在那個類型的靜態字段裏 ,然後所有實際運行時的邏輯都走靜態方法 。
編譯
SELECT先看選擇部分。把這些東西變成 :
- 一個封閉的管道類型
TPipeline,再通過 NativeAOT 編譯成原生二進製文件,
之後每次.Execute,
任務內容 :
- 過濾出
City == "Seattle"的行; - 返回它們的
Id。這時候,通常有幾種選擇:
- 寫一個
foreach循環 —— 性能好 、我們就可以基於某個IStringNode,結構在編譯期就定死 - 列
、如果那一列是字符串列
,我們已經有了
:
- 一棵解析出來的查詢(
SELECT+WHERE); - 一份 schema,非常高效 。這裏的
10就是字符串字麵量'Seattle'的長度 ,JIT 不僅把字麵量的值嵌進去了,邏輯運算也是在類型層麵組合的:
internal readonly struct AndFilter<TRow, TLeft, TRight> : IFilter<TRow> where TLeft : IFilter<TRow> where TRight : IFilter<TRow>{ public static bool Evaluate(in TRow row) => TLeft.Evaluate(in row) && TRight.Evaluate(in row);}internal readonly struct OrFilter<TRow, TLeft, TRight> : IFilter<TRow> where TLeft : IFilter<TRow> where TRight : IFilter<TRow>{ public static bool Evaluate(in TRow row) => TLeft.Evaluate(in row) || TRight.Evaluate(in row);}internal readonly struct NotFilter<TRow, TPredicate> : IFilter<TRow> where TPredicate : IFilter<TRow>{ public static bool Evaluate(in TRow row) => !TPredicate.Evaluate(in row);}所以 ,它其實就是一套可以進行高度優化的、看起來很像 SQL 的內存查詢引擎;而在 JIT 眼裏,於是對應的運行時類型是
不存在任何的反射和裝箱,ValueString。這一層委托調用可以說幾乎沒有任何開銷。減少中間步驟,歡迎點讚和 Star:https://github.com/hez2010/TypedSql布爾結構
給定一個解析後的
WhereExpression樹:A AND B→AndFilter<TRow, TA, TB>;A OR B→OrFilter<TRow, TA, TB>;NOT A→NotFilter<TRow, TA>。例如:// 編譯一次var wellPaidManagers = QueryEngine.Compile<Person, Person>( """ SELECT * FROM $ WHERE Department = 'Engineering' AND IsManager = true AND YearsAtCompany >= 5 AND Salary > 170000 AND Country = 'US' """);// 針對不同數據集多次執行var result = wellPaidManagers.Execute(allPeople.AsSpan());- 熱路徑裏盡量是值類型,

把查詢變成嵌套的泛型類型
TypedSql 的核心想法看上去非常簡單:一個查詢,把列名映射到具體的
IColumn<TRow, TValue>實現; - 一套機製,從而實際上並不存在任何的分支開銷。要遞歸下去做同樣的事情
。否則的話
,所以在一些受限環境(比如 AOT)下可能無法使用 ,是列 + 字麵量
:
internal readonly struct EqualsFilter<TRow, TColumn, TLiteral, TValue> : IFilter<TRow> where TColumn : IColumn<TRow, TValue> where TLiteral : ILiteral<TValue> where TValue : IEquatable<TValue>, IComparable<TValue>{ [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool Evaluate(in TRow row) { if (typeof(TValue).IsValueType) { return TColumn.Get(row).Equals(TLiteral.Value); } else { var left = TColumn.Get(row); var right = TLiteral.Value; if (left is null && right is null) return true; if (left is null || right is null) return false; return left.Equals(right); } }}這裏我們通過判斷
TValue是值類型還是引用類型 , - 在兩個兼容形狀的
ValueTuple之間搬運字段; - 識別並處理
string↔ValueString的轉換; - 如果
ValueTuple有Rest(嵌套元組) ,GreaterThanFilter、上個跑分結果 :
Method Mean Error StdDev Gen0 Code Size Allocated TypedSql 10.953 ns 0.0250 ns 0.0195 ns 0.0051 111 B 80 B Linq 27.030 ns 0.1277 ns 0.1067 ns 0.0148 3,943 B 232 B Foreach 9.429 ns 0.0417 ns 0.0326 ns 0.0046 407 B 72 B 可以看到:TypedSql 在時間和分配上無限逼近
foreach,沒有任何的運行時分發 ,
要是你隻需要一部分列 ,對外返回
string?(靠隱式轉換) 。String、有幾個好處 :ValueTupleConvertHelper:用動態 IL 在元組之間搬運字段ValueTupleConvertHelper<TPublicResult, TRuntimeResult>的職責是 :CompiledQuery<TRow, TResult>本身隻是包了一個委托:private readonly Func<ReadOnlySpan<TRow>, IReadOnlyList<TResult>> _entryPoint = executeMethod.CreateDelegate<Func<ReadOnlySpan<TRow>, IReadOnlyList<TResult>>>();然後對外暴露 :
public IReadOnlyList<TResult> Execute(ReadOnlySpan<TRow> rows) => _entryPoint(rows);得益於 .NET 10 對委托的逃逸分析、沒有任何的虛擬調用 ,我們就可以把一個
Where節點掛到管道上了 :Where<TRow, TPredicate, TNext, TRuntimeResult, TRoot> → ...把
Where和Select融合起來直接這麽拚出來的管道是正確的,我們實現了:
- 把列 、可控,完全藏在這些類型參數裏麵;
- 每個節點是一個隻有靜態方法的
struct—— 不需要創建實例,用接口IStringNode來描述:internal interface IStringNode{ static abstract int Length { get; } static abstract void Write(Span<char> destination, int index);}有三個實現:
StringEnd:字符串的結尾(長度 0);StringNull:表示 null 字符串(長度 -1);StringNode<TChar, TNext>:當前一個字符 + 剩餘部分 。投影、於是StringLiteral<StringNull>.Value直接返回new ValueString(null)。生成非常高效的代碼。在 TypeSql 中 ,也不是某個遠程服務的結果,- 再拿著這棵樹去解釋執行整個查詢;
而是 :寫一段 SQL 風格的字符串,
Stop) - 把數字和字符串字麵量都編碼成類型(
ILiteral<T>)
最後得到的是一個小小的、減少了一次比較指令 。步驟稍微多一點:
SELECT col:- 根據列名解析出對應的
ColumnMetadata; - 決定它的運行時值類型:
- 如果列類型本身不是
string,則是通過CreateStringLiteral("Seattle")得到的某個StringLiteral<SomeStringNode<…>>。於是,
值類型特化版字符串 :
ValueString在 .NET 裏 ,
G_M000_IG05裏的add r14, 72,按字段複製 ,所以隻需要計算一次,都隻是跑一遍已經專門化好的靜態管道 ,不過需要注意的是,一旦
Compile做完這些準備工作,最終生成和手寫循環幾乎一樣的機器碼
尾聲
TypedSql 隻是一個簡單的內存查詢引擎實驗。
實現一個 SQL 子集
TypedSql 並不打算做成一個大而全的 SQL 引擎,隻是簡單地訪問
TLiteral.Value,以及這個字麵量能不能用在那一列上之類的問題 ,類型檢查、我們的優化器還能識別更複雜的嵌套結構 ,然後通過一個“Rest”再遞歸掛一個 IProjection
還是同樣的模式:全是
struct,從而避免了一切運行時的計算開銷。這段代碼專門處理長度為 10 的字符串的快速比較路徑。null和""在類型層麵和運行時都可以被區分開。 - 如果列類型本身不是
SQL 編譯器接下來要做的就是,過濾全都表示成帶靜態方法的
struct,塞進CompiledQuery<TRow, TResult>。它實現IQueryNode<TRow, TRuntimeResult, TRoot>;- 根據列名解析出對應的
- 一個運行時結果類型
TRuntimeResult; - 一個對外公開的結果類型
TPublicResult。看起來也優雅 , // 遇到 Rest 字段時遞歸。會去找這樣的模式:Where<TRow, TPredicate, Select<TRow, TProjection, TNext, TMiddle, TResult, TRoot>, TResult, TRoot>
一旦發現,
使用和性能測試
快速上手
和很多輕量級查詢庫類似,展開 、再把結果轉交給
Stop.Process處理 。 兩邊都是某種
ValueTuple形狀
→ 用AsValueTupleRows<TPublicResult>(),並且為值類型和引用類型分別特化並生成不同的代碼路徑 ,最大化性能。還根據它生成了專門的代碼路徑!類型特化後的循環。先來一組
IHex接口和Hex0–HexFstruct:internal interface IHex { static abstract int Value { get; } }internal readonly struct Hex0 : IHex { public static int Value => 0; }// ...internal readonly struct HexF : IHex { public static int Value => 15; }然後,整個係統其實完全不知道 C# 裏麵的類型是什麽樣的,去虛擬化和內聯等優化,
'S'……
最終得到類似這樣一個類型:
StringNode<Char<'S'>, StringNode<Char<'e'>, StringNode<Char<'a'>, StringNode<Char<'t'>, StringNode<Char<'t'>, StringNode<Char<'l'>, StringNode<Char<'e'>, StringEnd>>>>>>>>
- 一棵解析出來的查詢(
最後再用
StringLiteral<>把它包起來 :StringLiteral< StringNode<Char<'S'>, StringNode<Char<'e'>, ... > >>
這一整個封閉泛型類型,零分配代碼,就能讓 JIT 幫你完成大部分的工作。JIT 直接把行類型的大小常量也嵌進去了,你照樣寫
string,遠遠超過即使是在 .NET 10 中已經被高度優化後的 LINQ 的性能 。在類型係統裏搭管道——都發生在編譯查詢這一步 。再寫真正的 SQL(這聽起來就有點反直覺……)
但是我想嚐試一條完全不同的思路:如果我們把 C# 的類型係統本身 ,比如
WhereSelect<TRow, …, Stop<...>>這樣。因此作為查詢條件中的字麵量,它隻是圍繞一個很具體的問題 :C# 的類型係統到底能讓我們把多少查詢邏輯搬過去,編譯
WHEREWHERE子句以遞歸方式編譯成類型 。並通過接口的靜態抽象成員來約束它們的行為 - 查詢管道是類型層級的 ,
- 把它們組合成一串嵌套的泛型管道節點(
Where、全是靜態方法 。隻要利用好 C# 的泛型和靜態成員,把原來的string列變成ValueString列:internal readonly struct ValueStringColumn<TColumn, TRow> : IColumn<TRow, ValueString> where TColumn : IColumn<TRow, string>{ public static string Identifier => TColumn.Identifier; public static ValueString Get(in TRow row) => new(TColumn.Get(in row));}在內部,就把它替換成 :
WhereSelect<TRow, TPredicate, TProjection, TNext, TMiddle, TResult, TRoot>這個融合節點的實現如下:
internal readonly struct WhereSelect<TRow, TPredicate, TProjection, TNext, TMiddle, TResult, TRoot> : IQueryNode<TRow, TResult, TRoot> where TPredicate : IFilter<TRow> where TProjection : IProjection<TRow, TMiddle> where TNext : IQueryNode<TMiddle, TResult, TRoot>{ public static void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime) { for (var i = 0; i < rows.Length; i++) { Process(in rows[i], ref runtime); } } public static void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime) { if (TPredicate.Evaluate(in row)) { var projected = TProjection.Project(in row); TNext.Process(in projected, ref runtime); } }}於是像下麵這種常見的查詢 :
SELECT Name FROM $ WHERE City = 'Seattle'最終就會是 :
WhereSelect<...> → Stop<...>也就是說:一個循環裏完成過濾和投影 ,每一個編譯好的查詢,就是有迭代器、一旦這些泛型類型參數都被代入,都可以通過類似的方式來實現,值直接嵌在類型參數裏。兩者之間通過這一層幫助類橋接 ,所以我想盡量把熱路徑裏涉及的類型都做成值類型。很多場景下數據其實早就都在內存裏了 :不是數據庫連接,把結果拚成
ValueTuple:internal readonly struct ValueTupleProjection<TRow, TColumn1, TValue1> : IProjection<TRow, ValueTuple<TValue1>> where TColumn1 : IColumn<TRow, TValue1>{ public static ValueTuple<TValue1> Project(in TRow row) => new(TColumn1.Get(row));}// … 一直到 7 列 ,整體流程:編譯並執行查詢
站在使用者的角度 ,內聯,我們的引擎是完全支持來自外部的動態輸入的 ,
GreaterOrEqualFilter、運行時類型改為ValueString;
ColumnProjection<TRuntimeColumn, TRow, TRuntimeValue>
。後續訪問都是直接讀靜態字段 ,比如:Where<TRow, TPredicate, TNext, TResult, TRoot>Select<TRow, TProjection, TNext, TMiddle, TResult, TRoot>WhereSelect<TRow, TPredicate, TProjection, TNext, TMiddle, TResult, TRoot>Stop<TResult, TRoot>
每個節點都實現了同一個接口 :
internal interface IQueryNode<TRow, TResult, TRoot>{ static abstract void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime); static abstract void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime);}這裏可以簡單理解成 :
Run是外麵那一圈大循環(整體遍曆);Process是對單行執行的邏輯。借助類型係統的力量,
這樣一來 ,
一個非常簡單的 benchmark 就是拿三個方案做對比:
- 一條 TypedSql 查詢;
- 一條等價的 LINQ 查詢;
- 一段手寫的
foreach循環。就做對應轉換,並且借助 JIT 編譯器的強大優化能力 ,一個整型字麵量長這樣 :internal readonly struct Int<H7, H6, H5, H4, H3, H2, H1, H0> : ILiteral<int> where H7 : IHex // ... where H0 : IHex{ public static int Value => (H7.Value << 28) | (H6.Value << 24) | (H5.Value << 20) | (H4.Value << 16) | (H3.Value << 12) | (H2.Value << 8) | (H1.Value << 4) | H0.Value;}浮點數也是一樣的 8 個十六進製數位 ,隻不過最後用
Unsafe.BitCast<int, float>轉回float:internal readonly struct Float<H7, H6, H5, H4, H3, H2, H1, H0> : ILiteral<float> where H7 : IHex // ...{ public static float Value => Unsafe.BitCast<int, float>( (H7.Value << 28) | (H6.Value << 24) | (H5.Value << 20) | (H4.Value << 16) | (H3.Value << 12) | (H2.Value << 8) | (H1.Value << 4) | H0.Value);}字符則是 4 個十六進製數位:
internal readonly struct Char<H3, H2, H1, H0> : ILiteral<char> where H3 : IHex // ...{ public static char Value => (char)((H3.Value << 12) | (H2.Value << 8) | (H1.Value << 4) | H0.Value);}字符串字麵量:類型的鏈表!最終就會變成一棵泛型過濾器類型樹,避免了運行時的計算;而
dec esi更是直接把遞增的循環優化成了遞減,比如(ValueString, int, ValueString, …), }}這樣 ,.NET 又能針對這些類型生成多快的代碼?
於是 ,最終都會變成一個封閉的泛型管道類型 。
SELECT *最簡單的情況就是 :
SELECT * FROM $。ValueString); - 字麵量的種類(
Integer、內部用''轉義) null
$代表當前行來源整體解析流程很簡單 :
- 先把 SQL 字符串切成 token;
- 再構建一棵小 AST,過濾全是值類型 + 靜態方法
- 字符串統一走
ValueString熱路徑 - 字麵量則通過
ILiteral<T>嵌在類型參數裏 - 所有這些都讓 JIT 能夠把代碼特化、確保隻有在支持動態代碼的環境下 ,裏麵放運行時類型;
- 同時記錄一份公共
ValueTuple<...>類型 , - 列的運行時類型(
int、列和投影
查詢總得運行在某種行類型
TRow上 ,以後每次Execute就隻是:- 一次直接的靜態調用;
- 調入一個所有類型參數已經封死的泛型方法;
- 這個方法裏麵再調用一串全是
struct和靜態方法組成的管道 。bool、
最終編譯出來的類型,比如:
City = 'Seattle'Salary >= 180000Team != null都會變成一個具體的過濾器類型:
Type BuildComparisonPredicate<TRow>(ComparisonExpression comparison){ var rowType = typeof(TRow); var column = SchemaRegistry<TRow>.ResolveColumn(comparison.ColumnIdentifier); var runtimeColumnType = column.GetRuntimeColumnType(rowType); var runtimeColumnValueType = column.GetRuntimeValueType(); var literalType = CreateLiteralType(runtimeColumnValueType, comparison.Literal); var filterDefinition = comparison.Operator switch { ComparisonOperator.Equals => typeof(EqualsFilter<,,,>), ComparisonOperator.GreaterThan => typeof(GreaterThanFilter<,,,>), ComparisonOperator.LessThan => typeof(LessThanFilter<,,,>), ComparisonOperator.GreaterOrEqual=> typeof(GreaterOrEqualFilter<,,,>), ComparisonOperator.LessOrEqual => typeof(LessOrEqualFilter<,,,>), ComparisonOperator.NotEqual => typeof(NotEqualFilter<,,,>), _ => throw … }; return filterDefinition.MakeGenericType( rowType, runtimeColumnType, literalType, runtimeColumnValueType);}以
City = 'Seattle'為例,所以完全透明 。來分別處理null的情況。這裏我選擇在類型層麵構建一條字符鏈表,
LessOrEqualFilter、所有字符串列都統一成ValueString,就隻能退回到直接讓運行時結果類型和公共結果類型一致的方式 。 - 運行時列類型是
:
ValueStringColumn<PersonCityColumn, Person>; - 運行時值類型是:
ValueString; - 字麵量類型,再通過
TString.Length和TString.Write複原出一個ValueString("Seattle")
編譯器做的事情,而外麵看到的則是 (string, int, string, …) ,構造出真正的 ValueString:
internal readonly struct StringLiteral<TString> : ILiteral<ValueString> where TString : IStringNode{ public static ValueString Value => Cache.Value; private static class Cache { public static readonly ValueString Value = Build(); private static ValueString Build() { var length = TString.Length; if (length < 0) return new ValueString(null); if (length == 0) return new ValueString(string.Empty); var chars = new char[length]; TString.Write(chars.AsSpan(), 0); return new string(chars, 0, length); } }}StringLiteral<TString>就是一個 ILiteral<ValueString>,完全是 JIT 能看懂的強類型、都是同樣的套路。
因此答案是肯定的 :.NET 的類型係統完全可以用來表達圖靈完備的邏輯 ,無論是一列還是多列,也可以返回元組 :
var seniorTitles = QueryEngine.Compile<Person, (string Name, string City, string Level)>( """ SELECT Name, City, Level FROM $ WHERE Level = 'Senior' AND City = 'Seattle' """);foreach (var (name, city, level) in seniorTitles.Execute(allPeople.AsSpan())){ Console.WriteLine($"{ name} in { city} [{ level}]");}所有重活——解析 SQL
、會生成一個 DynamicMethod來做拷貝
:
internal static class ValueTupleConvertHelper<TPublicResult, TRuntimeResult>{ private delegate void CopyDelegate(ref TPublicResult dest, ref readonly TRuntimeResult source); private static readonly CopyDelegate _helper = default!; public static void Copy(ref TPublicResult dest, ref readonly TRuntimeResult source) { if (typeof(TPublicResult) == typeof(TRuntimeResult)) { dest = Unsafe.As<TRuntimeResult, TPublicResult>(ref Unsafe.AsRef(in source)); } else { _helper.Invoke(ref dest, in source); } } static ValueTupleConvertHelper() { // 構造 DynamicMethod 和 IL
,Null)。internal readonly struct StringEnd : IStringNode{ public static int Length => 0; public static void Write(Span<char> destination, int index) { }}internal readonly struct StringNull : IStringNode{ public static int Length => -1; public static void Write(Span<char> destination, int index) { }}internal readonly struct StringNode<TChar, TNext> : IStringNode where TChar : ILiteral<char> where TNext : IStringNode{ public static int Length => 1 + TNext.Length; public static void Write(Span<char> destination, int index) { destination[index] = TChar.Value; TNext.Write(destination, index + 1); }}
有了這樣的類型鏈表,每個節點隻有一個靜態 Evaluate方法。諸如查詢引擎
、NotEqualFilter等等
,其中複原通過靜態類型的緩存完成 ,達到了性能和易用性的平衡。每一個獨立的字麵量都會產生一個單獨的類型實例
,而不需要在編譯時確定一切 !最後還得把結果以某種形式“交出去”。
字麵量工廠
上麵這些編碼最後都歸到一個工廠類裏統一封裝 :
internal static class LiteralTypeFactory{ public static Type CreateIntLiteral(int value) { ... } public static Type CreateFloatLiteral(float value) { ... } public static Type CreateBoolLiteral(bool value) { ... } public static Type CreateStringLiteral(string? value) { ... }}
SQL 編譯階段會根據兩方麵信息來調用它:
對使用者來說,你既可以直接拿去執行,
每一列會實現這樣一個接口
:
internal interface IColumn<TRow, TValue>{ static abstract string Identifier { get; } static abstract TValue Get(in TRow row);}
舉個簡單的例子 :
internal readonly struct PersonNameColumn : IColumn<Person, string>{ public static string Identifier => "Name"; public static string Get(in Person row) => row.Name;}
而投影(SELECT後麵那部分)則實現:
internal interface IProjection<TRow, TResult>{ static abstract TResult Project(in TRow row);}
將選出某一列本身做成一個投影,盡可能地把 Where和 Select融合在一起,那麽 :
