找回密码
 立即注册
首页 业界区 业界 TypedSql:在 C# 类型系统上实现一个 SQL 查询引擎 ...

TypedSql:在 C# 类型系统上实现一个 SQL 查询引擎

戈森莉 2025-11-24 01:05:05
前言

在 .NET 里写查询的时候,很多场景下数据其实早就都在内存里了:不是数据库连接,也不是某个远程服务的结果,而就是一个数组或者 List。我只是想过滤一下、投影一下。这时候,通常有几种选择:

  • 写一个 foreach 循环 —— 性能好、可控,但代码稍微有点啰嗦;
  • 用 LINQ —— 写起来舒服,看起来也优雅,就是有迭代器、委托带来的那点开销;
  • 要么干脆极端一点:把数据塞进数据库,再写真正的 SQL(这听起来就有点反直觉……)
但是我想尝试一条完全不同的思路:如果我们把 C# 的类型系统本身,当成查询计划会怎样?
也就是说,不是像平时那样:

  • 在运行时构建一棵表达式树,
  • 再拿着这棵树去解释执行整个查询;
而是:写一段 SQL 风格的字符串,把它编译成一个类型,这个类型从头到尾描述了整个查询管道,然后所有实际运行时的逻辑都走静态方法。
这个想法最终促成了 TypedSql —— 一个用 C# 类型系统实现的内存内 SQL 查询引擎。
把查询变成嵌套的泛型类型

TypedSql 的核心想法看上去非常简单:一个查询,其实可以是一串嵌套的泛型类型,比如 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);
} 这样。
顺着这个想法,再往下推几步,会自然落到一套具体的设计上。
把执行计划塞进类型系统

在 TypedSql 里,每一个编译好的查询,最终都会变成一个封闭的泛型管道类型
这个管道是由一些基础节点拼出来的,比如:

  • Where
  • Select
  • 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);
    }
  • Stop
每个节点都实现了同一个接口:
  1. internal interface IQueryNode<TRow, TResult, TRoot>
  2. {
  3.     static abstract void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime);
  4.     static abstract void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime);
  5. }
复制代码
这里可以简单理解成:

  • Run 是外面那一圈大循环(整体遍历);
  • Process 是对单行执行的逻辑。
比如 Where 节点大概长这样:
  1. internal readonly struct Where<TRow, TPredicate, TNext, TResult, TRoot>
  2.     : IQueryNode<TRow, TResult, TRoot>
  3.     where TPredicate : IFilter<TRow>
  4.     where TNext : IQueryNode<TRow, TResult, TRoot>
  5. {
  6.     public static void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime)
  7.     {
  8.         for (var i = 0; i < rows.Length; i++)
  9.         {
  10.             Process(in rows[i], ref runtime);
  11.         }
  12.     }
  13.     public static void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime)
  14.     {
  15.         if (TPredicate.Evaluate(in row))
  16.         {
  17.             TNext.Process(in row, ref runtime);
  18.         }
  19.     }
  20. }
复制代码
关键点在于:

  • 管道的形状,完全藏在这些类型参数里面;
  • 每个节点是一个只有静态方法的 struct —— 不需要创建实例,没有虚调用。
对 JIT 来说,一旦这些泛型类型参数都被代入,这就是一张普通的静态调用图而已。
列和投影

查询总得运行在某种行类型 TRow 上,这通常是你自己定义的一个 record/class/struct。
每一列会实现这样一个接口:
  1. internal interface IColumn<TRow, TValue>
  2. {
  3.     static abstract string Identifier { get; }
  4.     static abstract TValue Get(in TRow row);
  5. }
复制代码
举个简单的例子:
  1. internal readonly struct PersonNameColumn : IColumn<Person, string>
  2. {
  3.     public static string Identifier => "Name";
  4.     public static string Get(in Person row) => row.Name;
  5. }
复制代码
而投影(SELECT 后面那部分)则实现:
  1. internal interface IProjection<TRow, TResult>
  2. {
  3.     static abstract TResult Project(in TRow row);
  4. }
复制代码
将选出某一列本身做成一个投影,可以这么写:
  1. internal readonly struct ColumnProjection<TColumn, TRow, TValue>
  2.     : IProjection<TRow, TValue>
  3.     where TColumn : IColumn<TRow, TValue>
  4. {
  5.     public static TValue Project(in TRow row) => TColumn.Get(row);
  6. }
复制代码
多列选择时,TypedSql 会构造专门的投影,把结果拼成 ValueTuple:
  1. internal readonly struct ValueTupleProjection<TRow, TColumn1, TValue1>
  2.     : IProjection<TRow, ValueTuple<TValue1>>
  3.     where TColumn1 : IColumn<TRow, TValue1>
  4. {
  5.     public static ValueTuple<TValue1> Project(in TRow row)
  6.         => new(TColumn1.Get(row));
  7. }
  8. // … 一直到 7 列,然后通过一个“Rest”再递归挂一个 IProjection
复制代码
还是同样的模式:全是 struct,全是静态方法。
过滤器

过滤器的接口长这样:
  1. internal interface IFilter<TRow>
  2. {
  3.     static abstract bool Evaluate(in TRow row);
  4. }
复制代码
一个最常用的比较过滤器形式,是列 + 字面量:
  1. internal readonly struct internal interface IHex { static abstract int Value { get; } }
  2. internal readonly struct Hex0 : IHex { public static int Value => 0; }
  3. // ...
  4. internal readonly struct HexF : IHex { public static int Value => 15; }<TRow, TColumn, TLiteral, TValue> : IFilter<TRow>
  5.     where TColumn : IColumn<TRow, TValue>
  6.     where TLiteral : ILiteral<TValue>
  7.     where TValue : IEquatable<TValue>, IComparable<TValue>
  8. {
  9.     [MethodImpl(MethodImplOptions.AggressiveInlining)]
  10.     public static bool Evaluate(in TRow row)
  11.     {
  12.         if (typeof(TValue).IsValueType)
  13.         {
  14.             return TColumn.Get(row).Equals(TLiteral.Value);
  15.         }
  16.         else
  17.         {
  18.             var left = TColumn.Get(row);
  19.             var right = TLiteral.Value;
  20.             if (left is null && right is null) return true;
  21.             if (left is null || right is null) return false;
  22.             return left.Equals(right);
  23.         }
  24.     }
  25. }
复制代码
这里我们通过判断 TValue 是值类型还是引用类型,来分别处理 null 的情况。.NET 的 JIT 能够识别这种模式,并且为值类型和引用类型分别特化并生成不同的代码路径,从而实际上并不存在任何的分支开销。
GreaterThanFilter、LessThanFilter、GreaterOrEqualFilter、LessOrEqualFilter、NotEqualFilter 等等,都是同样的套路。
逻辑运算也是在类型层面组合的:
  1. internal readonly struct AndFilter<TRow, TLeft, TRight> : IFilter<TRow>
  2.     where TLeft : IFilter<TRow>
  3.     where TRight : IFilter<TRow>
  4. {
  5.     public static bool Evaluate(in TRow row)
  6.         => TLeft.Evaluate(in row) && TRight.Evaluate(in row);
  7. }
  8. internal readonly struct OrFilter<TRow, TLeft, TRight> : IFilter<TRow>
  9.     where TLeft : IFilter<TRow>
  10.     where TRight : IFilter<TRow>
  11. {
  12.     public static bool Evaluate(in TRow row)
  13.         => TLeft.Evaluate(in row) || TRight.Evaluate(in row);
  14. }
  15. internal readonly struct NotFilter<TRow, TPredicate> : IFilter<TRow>
  16.     where TPredicate : IFilter<TRow>
  17. {
  18.     public static bool Evaluate(in TRow row)
  19.         => !TPredicate.Evaluate(in row);
  20. }
复制代码
所以,一条 WHERE 子句,最终就会变成一棵泛型过滤器类型树,每个节点只有一个静态 Evaluate 方法。
值类型特化版字符串:ValueString

在 .NET 里,string 是一个引用类型,这给 TypedSql 带来了一些麻烦:.NET 会对引用类型采用共享泛型在运行时做分发,而不是为 string 泛型实例化一个具体类型,这使得运行时会产生类型字典查找的开销。虽然这点开销不大,但是 TypedSql 追求的是媲美手写循环的性能,所以我想尽量把热路径里涉及的类型都做成值类型。
于是我选择把字符串包在一个小的值类型里:
  1. internal readonly struct ValueString(string? value) : IEquatable<ValueString>, IComparable<ValueString>
  2. {
  3.     public readonly string? Value = value;
  4.     public int CompareTo(ValueString other)
  5.         => string.Compare(Value, other.Value, StringComparison.Ordinal);
  6.     public bool Equals(ValueString other)
  7.     {
  8.         return string.Equals(Value, other.Value, StringComparison.Ordinal);
  9.     }
  10.     public override string? ToString() => Value;
  11.     public static implicit operator ValueString(string value) => new(value);
  12.     public static implicit operator string?(ValueString value) => value.Value;
  13. }
复制代码
再配一个适配器,把原来的 string 列变成 ValueString 列:
  1. internal readonly struct ValueStringColumn<TColumn, TRow>
  2.     : IColumn<TRow, ValueString>
  3.     where TColumn : IColumn<TRow, string>
  4. {
  5.     public static string Identifier => TColumn.Identifier;
  6.     public static ValueString Get(in TRow row)
  7.         => new(TColumn.Get(in row));
  8. }
复制代码
在内部,所有字符串列都统一成 ValueString,有几个好处:

  • 热路径里尽量是值类型,少一点引用类型的干扰;
  • 避开了泛型共享带来的类型字典查找开销。
对使用者来说,你照样写 string,而我的 TypedSql 会在内部自动在边缘位置做封装/解封装,所以完全透明。
实现一个 SQL 子集

TypedSql 并不打算做成一个大而全的 SQL 引擎,而是针对单表、内存内查询,设计了一个很小的 SQL 方言:
支持这些语句:
<ul>SELECT * FROM $
SELECT col FROM $
SELECT col1, col2, ... FROM $
WHERE 支持:<ul>
比较:=, !=, >, =,  0; }// ...internal readonly struct HexF : IHex { public static int Value => 15; }[/code]然后,一个整型字面量长这样:
  1. internal interface ILiteral<T>
  2. {
  3.     static abstract T Value { get; }
  4. }
复制代码
以 City = 'Seattle' 为例,如果那一列是字符串列,那么:

  • 运行时列类型是:ValueStringColumn;
  • 运行时值类型是:ValueString;
  • 字面量类型,则是通过 CreateStringLiteral("Seattle") 得到的某个 StringLiteral。
最后组合出一个过滤器类型:
  1. internal interface IHex { static abstract int Value { get; } }
  2. internal readonly struct Hex0 : IHex { public static int Value => 0; }
  3. // ...
  4. internal readonly struct HexF : IHex { public static int Value => 15; }
复制代码
到这一步,我们就可以把一个 Where 节点挂到管道上了:
  1. internal readonly struct Int<H7, H6, H5, H4, H3, H2, H1, H0> : ILiteral<int>
  2.     where H7 : IHex
  3.     // ...
  4.     where H0 : IHex
  5. {
  6.     public static int Value
  7.         => (H7.Value << 28)
  8.          | (H6.Value << 24)
  9.          | (H5.Value << 20)
  10.          | (H4.Value << 16)
  11.          | (H3.Value << 12)
  12.          | (H2.Value <<  8)
  13.          | (H1.Value <<  4)
  14.          |  H0.Value;
  15. }
复制代码
把 Where 和 Select 融合起来

直接这么拼出来的管道是正确的,但在性能上还能再优化一点:
Where 和 Select 其实可以合并成一步。
TypedSql 里有一个很小的优化器,会去找这样的模式:

  • Where
一旦发现,就把它替换成:
  1. internal readonly struct Float<H7, H6, H5, H4, H3, H2, H1, H0> : ILiteral<float>
  2.     where H7 : IHex
  3.     // ...
  4. {
  5.     public static float Value
  6.         => Unsafe.BitCast<int, float>(
  7.                (H7.Value << 28)
  8.              | (H6.Value << 24)
  9.              | (H5.Value << 20)
  10.              | (H4.Value << 16)
  11.              | (H3.Value << 12)
  12.              | (H2.Value <<  8)
  13.              | (H1.Value <<  4)
  14.              |  H0.Value);
  15. }
复制代码
这个融合节点的实现如下:
  1. internal readonly struct internal readonly struct Float<H7, H6, H5, H4, H3, H2, H1, H0> : ILiteral<float>
  2.     where H7 : IHex
  3.     // ...
  4. {
  5.     public static float Value
  6.         => Unsafe.BitCast<int, float>(
  7.                (H7.Value << 28)
  8.              | (H6.Value << 24)
  9.              | (H5.Value << 20)
  10.              | (H4.Value << 16)
  11.              | (H3.Value << 12)
  12.              | (H2.Value <<  8)
  13.              | (H1.Value <<  4)
  14.              |  H0.Value);
  15. }    : IQueryNode    where TPredicate : IFilter    where TProjection : IProjection    where TNext : IQueryNode{    public static void Run(ReadOnlySpan rows, scoped ref QueryRuntime 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 runtime)    {        if (TPredicate.Evaluate(in row))        {            var projected = TProjection.Project(in row);            TNext.Process(in projected, ref runtime);        }    }}
复制代码
于是像下面这种常见的查询:
  1. internal interface IStringNode
  2. {
  3.     static abstract int Length { get; }
  4.     static abstract void Write(Span<char> destination, int index);
  5. }
复制代码
最终就会是:
  1. internal readonly struct Float<H7, H6, H5, H4, H3, H2, H1, H0> : ILiteral<float>
  2.     where H7 : IHex
  3.     // ...
  4. {
  5.     public static float Value
  6.         => Unsafe.BitCast<int, float>(
  7.                (H7.Value << 28)
  8.              | (H6.Value << 24)
  9.              | (H5.Value << 20)
  10.              | (H4.Value << 16)
  11.              | (H3.Value << 12)
  12.              | (H2.Value <<  8)
  13.              | (H1.Value <<  4)
  14.              |  H0.Value);
  15. } → Stop
复制代码
也就是说:一个循环里完成过滤和投影,不需要再分两趟。并且,我们的优化器还能识别更复杂的嵌套结构,尽可能地把 Where 和 Select 融合在一起,减少中间步骤,提升性能。而这并不需要复杂的优化算法,只需要简单地把泛型参数取出来重新带入到新的融合类型即可,实现起来非常简单。
结果转换

管道把所有行跑完之后,最后还得把结果以某种形式“交出去”。
一个查询的入口长这样:
  1. internal readonly struct StringLiteral<TString> : ILiteral<ValueString>
  2.     where TString : IStringNode
  3. {
  4.     public static ValueString Value => Cache.Value;
  5.     private static class Cache
  6.     {
  7.         public static readonly ValueString Value = Build();
  8.         private static ValueString Build()
  9.         {
  10.             var length = TString.Length;
  11.             if (length < 0) return new ValueString(null);
  12.             if (length == 0) return new ValueString(string.Empty);
  13.             var chars = new char[length];
  14.             TString.Write(chars.AsSpan(), 0);
  15.             return new string(chars, 0, length);
  16.         }
  17.     }
  18. }
复制代码
可以看到主要有三种情况:

  • 运行时结果类型和公共结果类型一模一样
    → 直接把 Rows 返回就行。
  • 运行时内部用的是 ValueString,外面希望看到 string
    → 调用 AsStringRows,它会把内部的 ValueString[] 包装一下,对外返回 string?(靠隐式转换)。
  • 两边都是某种 ValueTuple 形状
    → 用 AsValueTupleRows(),底层交给 ValueTupleConvertHelper 去做拷贝和字段转换。
ValueTupleConvertHelper:用动态 IL 在元组之间搬运字段

ValueTupleConvertHelper 的职责是:

  • 在两个兼容形状的 ValueTuple 之间搬运字段;
  • 识别并处理 string ↔ ValueString 的转换;
  • 如果 ValueTuple 有 Rest(嵌套元组),要递归下去做同样的事情。
它在类型初始化时,会生成一个 DynamicMethod 来做拷贝:
  1. public static Type CreateStringLiteral(string? value)
  2. {
  3.     if (value is null)
  4.     {
  5.         return typeof(StringLiteral<StringNull>);
  6.     }
  7.     var type = typeof(StringEnd);
  8.     for (var i = value.Length - 1; i >= 0; i--)
  9.     {
  10.         var charType = CreateCharType(value[i]); // Char<...>
  11.         type = typeof(StringNode<,>).MakeGenericType(charType, type);
  12.     }
  13.     return typeof(StringLiteral<>).MakeGenericType(type);
  14. }
复制代码
这样,运行时内部可以用一个对自己更舒服的元组类型,比如 (ValueString, int, ValueString, …),而外面看到的则是 (string, int, string, …),两者之间通过这一层帮助类桥接,成本也很低。这使得查询过程可以最大化利用值类型的泛型特化优势,同时对外还不需要暴露这些内部细节,达到了性能和易用性的平衡。
不过需要注意的是,这一块用到了动态代码生成,所以在一些受限环境(比如 AOT)下可能无法使用,因此 TypedSql 会在编译阶段检查这一点,确保只有在支持动态代码的环境下,才允许使用这种元组转换。否则的话,就只能退回到直接让运行时结果类型和公共结果类型一致的方式。
整体流程:编译并执行查询

站在使用者的角度,入口一般会是这样的:
  1. StringNode<Char<'S'>,
  2.   StringNode<Char<'e'>,
  3.     StringNode<Char<'a'>,
  4.       StringNode<Char<'t'>,
  5.         StringNode<Char<'t'>,
  6.           StringNode<Char<'l'>,
  7.             StringNode<Char<'e'>, StringEnd>>>>>>>>
复制代码
Compile 在内部会做这么几件事:

  • 解析 SQL,生成 ParsedQuery;
  • 把 SQL 编译成:

    • 管道类型 TPipeline;
    • TRuntimeResult;
    • TPublicResult;

  • 检查 TPublicResult 是否和你指定的 TResult 一致;
  • 构造 QueryProgram 这个类型;
  • 找到它的静态方法 Execute(ReadOnlySpan);
  • 把它变成一个委托,塞进 CompiledQuery。
CompiledQuery 本身只是包了一个委托:
  1. StringLiteral<
  2.   StringNode<Char<'S'>,
  3.     StringNode<Char<'e'>,
  4.       ...
  5.     >
  6.   >
  7. >
复制代码
然后对外暴露:
  1. internal static class LiteralTypeFactory
  2. {
  3.     public static Type CreateIntLiteral(int value) { ... }
  4.     public static Type CreateFloatLiteral(float value) { ... }
  5.     public static Type CreateBoolLiteral(bool value) { ... }
  6.     public static Type CreateStringLiteral(string? value) { ... }
  7. }
复制代码
得益于 .NET 10 对委托的逃逸分析、去虚拟化和内联等优化,这一层委托调用可以说几乎没有任何开销。
在 JIT 看来,一旦 Compile 做完这些准备工作,以后每次 Execute 就只是:

  • 一次直接的静态调用;
  • 调入一个所有类型参数已经封死的泛型方法;
  • 这个方法里面再调用一串全是 struct 和静态方法组成的管道。
最终编译出来的类型,你既可以直接拿去执行,也可以把它输出到代码里然后通过 NativeAOT 编译成原生二进制文件,一套代码同时支持 JIT 和 AOT!
使用和性能测试

快速上手

和很多轻量级查询库类似,TypedSql 的打开方法是:

  • 定义你的行类型,例如:
    1. TRuntimeResult = typeof(TRow);
    2. TPublicResult = typeof(TRow);
    3. TPipelineTail = typeof(Stop<,>).MakeGenericType(TRuntimeResult, typeof(TRow));
    复制代码
  • 为每一列实现一个 IColumn;
  • 把这些列注册到 Person 对应的 schema 里;
  • 然后就可以编译并运行查询,例如:
    1. Select<TRow, TProjection, Stop<...>, TMiddle, TRuntimeResult, TRoot> → Stop<...>
    复制代码
要是你只需要一部分列,也可以返回元组:
  1. Type BuildPredicate<TRow>(WhereExpression expr)
  2. {
  3.     return expr switch
  4.     {
  5.         ComparisonExpression cmpExpr => BuildComparisonPredicate<TRow>(cmpExpr),
  6.         AndExpression andExpr => typeof(AndFilter<,,>).MakeGenericType(typeof(TRow), BuildPredicate<TRow>(andExpr.Left), BuildPredicate<TRow>(andExpr.Right)),
  7.         OrExpression orExpr => typeof(OrFilter<,,>).MakeGenericType(typeof(TRow), BuildPredicate<TRow>(orExpr.Left), BuildPredicate<TRow>(orExpr.Right)),
  8.         NotExpression notExpr => typeof(NotFilter<,>).MakeGenericType(typeof(TRow), BuildPredicate<TRow>(notExpr.Expression)),
  9.         _ => throw …
  10.     };
  11. }
复制代码
所有重活——解析 SQL、字面量编码、在类型系统里搭管道——都发生在编译查询这一步。
之后每次 .Execute,都只是跑一遍已经专门化好的静态管道,没有任何的运行时分发,没有任何的虚拟调用,不存在任何的反射和装箱,完全是 JIT 能看懂的强类型、零分配代码,从而实现极高的性能。
简单性能对比

TypedSql 的目标并不是炫技用类型,而是想试试看:在保持 SQL 风格外壳的情况下,我们能让生成的代码离一个手写循环有多近。
一个非常简单的 benchmark 就是拿三个方案做对比:

  • 一条 TypedSql 查询;
  • 一条等价的 LINQ 查询;
  • 一段手写的 foreach 循环。
任务内容:

  • 过滤出 City == "Seattle" 的行;
  • 返回它们的 Id。
TypedSql 编译出来的类型大概是这样:
  1. QueryProgram<    Person,    internal readonly struct Float<H7, H6, H5, H4, H3, H2, H1, H0> : ILiteral<float>
  2.     where H7 : IHex
  3.     // ...
  4. {
  5.     public static float Value
  6.         => Unsafe.BitCast<int, float>(
  7.                (H7.Value << 28)
  8.              | (H6.Value << 24)
  9.              | (H5.Value << 20)
  10.              | (H4.Value << 16)
  11.              | (H3.Value << 12)
  12.              | (H2.Value <<  8)
  13.              | (H1.Value <<  4)
  14.              |  H0.Value);
  15. }<        Person,        internal interface IHex { static abstract int Value { get; } }
  16. internal readonly struct Hex0 : IHex { public static int Value => 0; }
  17. // ...
  18. internal readonly struct HexF : IHex { public static int Value => 15; }<            Person,            ValueStringColumn,            'Seattle',            ValueString        >,        ColumnProjection,        Stop,    Int32,    Int32,    Person>,Int32,Int32>
复制代码
让我们来看看 RyuJIT 为我们的查询方案生成了什么样的机器码:
  1. Type BuildComparisonPredicate<TRow>(ComparisonExpression comparison)
  2. {
  3.     var rowType = typeof(TRow);
  4.     var column = SchemaRegistry<TRow>.ResolveColumn(comparison.ColumnIdentifier);
  5.     var runtimeColumnType      = column.GetRuntimeColumnType(rowType);
  6.     var runtimeColumnValueType = column.GetRuntimeValueType();
  7.     var literalType = CreateLiteralType(runtimeColumnValueType, comparison.Literal);
  8.     var filterDefinition = comparison.Operator switch
  9.     {
  10.         ComparisonOperator.Equals        => typeof(EqualsFilter<,,,>),
  11.         ComparisonOperator.GreaterThan   => typeof(GreaterThanFilter<,,,>),
  12.         ComparisonOperator.LessThan      => typeof(LessThanFilter<,,,>),
  13.         ComparisonOperator.GreaterOrEqual=> typeof(GreaterOrEqualFilter<,,,>),
  14.         ComparisonOperator.LessOrEqual   => typeof(LessOrEqualFilter<,,,>),
  15.         ComparisonOperator.NotEqual      => typeof(NotEqualFilter<,,,>),
  16.         _ => throw …
  17.     };
  18.     return filterDefinition.MakeGenericType(
  19.         rowType, runtimeColumnType, literalType, runtimeColumnValueType);
  20. }
复制代码
注意看 G_M000_IG08 的 r8, 10,这里的 10 就是字符串字面量 'Seattle' 的长度,JIT 直接把我们的字符串字面量的长度常量嵌进了机器码里;进一步当长度匹配时,JIT 又生成了代码跳转到 G_M000_IG10,这段代码专门处理长度为 10 的字符串的快速比较路径。也就是说,JIT 不仅把字面量的值嵌进去了,还根据它生成了专门的代码路径!
再注意看循环计数器的更新部分,G_M000_IG05 里的 add r14, 72,这里的 72 就是 sizeof(Person),JIT 直接把行类型的大小常量也嵌进去了,避免了运行时的计算;而 dec esi 更是直接把递增的循环优化成了递减,减少了一次比较指令。
上述代码的逻辑等价于:
  1. EqualsFilter<Person,
  2.              ValueStringColumn<PersonCityColumn, Person>,
  3.              StringLiteral<...>,
  4.              ValueString>
复制代码
看到了吗?跟你手写的循环几乎一模一样!我们的抽象完全被 JIT 优化的一干二净!
上个跑分结果:
MethodMeanErrorStdDevGen0Code SizeAllocatedTypedSql10.953 ns0.0250 ns0.0195 ns0.0051111 B80 BLinq27.030 ns0.1277 ns0.1067 ns0.01483,943 B232 BForeach9.429 ns0.0417 ns0.0326 ns0.0046407 B72 B可以看到:TypedSql 在时间和分配上无限逼近 foreach,远远超过即使是在 .NET 10 中已经被高度优化后的 LINQ 的性能。
这也符合我们对它内部结构的预期:

  • 查询管道是类型层级的,结构在编译期就定死
  • 列、投影、过滤全是值类型 + 静态方法
  • 字符串统一走 ValueString 热路径
  • 字面量则通过 ILiteral 嵌在类型参数里
  • 所有这些都让 JIT 能够把代码特化、展开、内联,最终生成和手写循环几乎一样的机器码
尾声

TypedSql 只是一个简单的内存查询引擎实验。它只是围绕一个很具体的问题:C# 的类型系统到底能让我们把多少查询逻辑搬过去,.NET 又能针对这些类型生成多快的代码?
于是,在 TypeSql 中,我们实现了:

  • 把列、投影、过滤全都表示成带静态方法的 struct,并通过接口的静态抽象成员来约束它们的行为
  • 把它们组合成一串嵌套的泛型管道节点(Where、Select、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);
    }、Stop)
  • 把数字和字符串字面量都编码成类型(ILiteral)
最后得到的是一个小小的、看起来很像 SQL 的内存查询引擎;而在 JIT 眼里,它其实就是一套可以进行高度优化的、类型特化后的循环。
因此答案是肯定的:.NET 的类型系统完全可以用来表达图灵完备的逻辑,并且借助 JIT 编译器的强大优化能力,生成非常高效的代码。
展望未来的应用,诸如查询引擎、DSL 编译器、甚至是语言运行时等复杂系统,都可以通过类似的方式来实现,从而在保持灵活性的同时,最大化性能。而你甚至不需要实现任何的代码生成后端,只要利用好 C# 的泛型和静态成员,就能让 JIT 帮你完成大部分的工作。而把构建好的类型输出成代码文件,再通过 NativeAOT 编译成原生二进制文件,也同样是可行的。编写一次,同时支持 JIT 和 AOT,两全其美。并且不同于 C++ 的模板和 constexpr,我们的引擎是完全支持来自外部的动态输入的,而不需要在编译时确定一切!
本项目的代码已经开源在 GitHub 上,欢迎点赞和 Star:https://github.com/hez2010/TypedSql

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册