[深入解析C#] 第3章:C# 3 LINQ 及相关特性

C# 3 LINQ及其相关特性

3.1 自动实现的属性

问题:C# 3之前,每个属性都需要手动实现,先定义一个字段,再定义属性,代码冗余

private string _name;
public string Name
{
    get { return _name; }
    set { _name = value; }
}

自动实现的属性(自动属性)就简化了这个环节

public string Name { get; set; }

但需注意字段仍然存在,只不过由编译器自动创建并赋予名称了

C# 6后,可以声明只包含getter的只读自动属性,省略private set的写法

public string Name { get; }

3.2 隐式类型

3.2.1 类型术语

有两种分类法:

  • 弱类型和强类型,易产生歧义
  • 静态类型和动态类型、显式类型和隐式类型,更具体

1. 静态类型和动态类型

绑定:决定某个表达式“具体含义”(调用哪个方法、访问哪个字段…)的过程

动态类型的语言把绝大部分绑定操作放到了执行期

C#中有些表达式在源码层面不具有类型信息,比如null,但是编译器总是可以根据表达式所在上下文推断出其类型,然后根据该类型来检查表达式的使用是否正确。

总体上C#属于静态类型,但也有dynamic等动态类型的部分

2. 显式类型和隐式类型

C#总体属于显式类型,但也有隐式类型的身影,如泛型类型实参的类型推断,intlong的转换

3.2.2 隐式类型的局部变量

var关键字声明的变量

var lang = "C#";

需要注意var类型推断是编译时进行的,而非运行时

使用规则:

  • 变量在声明时必须被初始化
  • 用于初始化变量的表达式必须已经具备某个类型
// 没有提供初始值
var x;
x = 10;

var y = null; // 初始值没有类型

var的适用场景:

  • 变量为匿名类型
  • 变量类型名过长,且可根据初始化表达式轻松推断类型
  • 变量的精确类型不重要,且初始化表达式可提供足够信息推断

3.2.3 隐式类型的数组

需求:创建一个数组,但是暂时不需要添加数据,所有元素保持默认值

int[] array = new int[10];

C# 3前的初始化方式

int[] array1 = { 1, 2, 3, 4, 5 };
int[] array2 = new int[] { 1, 2, 3, 4, 5 };
int[] array;
array = { 1, 2, 3, 4, 5 }; // 非法
array = new int[] { 1, 2, 3, 4, 5 }; // 合法

C# 3引入了另一种方式

array = new[] { 1, 2, 3, 4, 5 };
// 多维数组也适用
var array = new[,] { { 1, 2, 3 }, { 4, 5, 6 } };

类型推断的大致流程:

  1. 统计每一个元素的类型,将这些类型整合成一个类型候选集。
  2. 对于类型候选集中的每一个类型,检查是否所有元素都可以隐式地转换为该类型。剔除不满足该检查条件的类型,最终得到一个筛选过的类型集。
  3. 如果该类型集中只剩一个类型,则该类型就是推断出来的元素类型,编译器根据该类型来创建合适的数组。如果类型集中类型的数量为0或大于1,则编译时会报错。

3.3 对象和集合的初始化

此处联想到的:Unity关于使用for、foreach、LINQ的性能差异,通常for的性能最高,foreach的bug已经在2022等新版unity中修复,LINQ有插件替代方案…

3.3.1 对象初始化器和集合初始化器简介

一个电商订单例子:

public class Order
{
    private readonly List<OrderItem> _items = new List<OrderItem>();
    
    public string OrderId { get; set; }
    public Customer Customer { get; set; }
    public List<OrderItem> Items
    {
        get { return _items; }
    }
}

public class Customer
{
    public string Name { get; set; }
    public string Address { get; set; }
}

public class OrderItem
{
    public string ItemId { get; set; }
    public int Quantity { get; set; }
}

创建和添加订单:

// 创建customer
var customer = new Customer();
customer.Name = "Jon";
customer.Address = "UK";
        
// 创建第一个OrderItem
var item1 = new OrderItem();
item1.ItemId = "A001";
item1.Quantity = 1;
        
// 创建第二个OrderItem
var item2 = new OrderItem();
item2.ItemId = "A002";
item2.Quantity = 2;
        
// 创建Order
var order = new Order();
order.OrderId = "O001";
order.Customer = customer;
order.Items.Add(item1);
order.Items.Add(item2);

除了使用构造器可以简化创建,还可以使用初始化器

// 创建Order
// 使用对象初始化器
var order = new Order
{
    OrderId = "O001",
    Customer = new Customer { Name = "Jon", Address = "UK" },
    // 使用集合初始化器
    Items =
    {
        new OrderItem { ItemId = "A001", Quantity = 1 },
        new OrderItem { ItemId = "A002", Quantity = 2 }
    }
};

可见使用初始化器后,代码的结构更清晰了

3.3.2 对象初始化器

🍀 对象初始化器常和属性搭配使用,这也是本章代码所采用的方式。字段没有访问器,但也可以采用类似于等价的方式:get访问器相当于字段的读操作,set访问器相当于字段的写操作。

对象初始化器只能用于构造器调用或者其他对象初始化器中

// 这两种写法是等价的
Order order = new Order() { OrderId = "xyz" };
Order order = new Order { OrderId = "xyz" };

⚠️ 只有在使用对象初始化器或者集合初始化器时,构造器的参数列表才可以省略

如果初始化值是另一个对象初始化器,则不会调用set访问器,而会调用get访问器,然后将嵌套对象初始化器得到的结果应用于由get访问器返回的属性。

HttpClient client = new HttpClient
{
    // 为 DefaultRequestHeaders调用的属性 get 访问器
    DefaultRequestHeaders =
    {
        // 为 From 调用的属性 set 访问器
        From = "user@example.com",
        // 为 Date 调用的属性 set 访问器
        Date = DateTimeOffset.UtcNow
    }
};

3.3.3 集合初始化器

var beatles = new List<string> { "John", "Paul", "Ringo", "George" };

编译器会将以上代码转换成一个构造器调用,其后紧跟一系列Add方法的调用

对于字典,写法如下

var releaseYears = new Dictionary<string, int>
{
    { "Please please me", 1963 },
    { "wowowo", 1966 }
};

添加元素的方法则是Add(key, value)

适用条件:

  • 实现了 System.Collections.IEnumerable(编译器仅依赖这个来判断“可能是集合”)
  • 提供了适用的 Add 方法(可以是实例方法,或 C# 6+ 的扩展方法)

总是用于创建新对象而非初始化已有集合变量

3.3.4 仅用单一表达式就能完成初始化的好处

好处:

  • 与LINQ相关的其他特性都要求代码具备单一表达式的表达能力
  • 有助于简化字段初始化器、方法实参甚至是条件表达式?:
  • 支持匿名类型

3.4 匿名类型

3.4.1 基本语法和行为

var player = new
{
    Name = "Rajesh",
    Score = 3500
};

Console.WriteLine("Player name: {0}", player.Name);
Console.WriteLine("Player score: {0}", player.Score);

匿名类型的几个要素:

  • 匿名对象创建表达式:匿名类型的语法类似于对象初始化器,但无须指定类型名称,只需要new关键字、左大括号、属性以及右大括号
  • 使用var声明变量
  • 以上代码依然属于静态类型的范畴

img

投射初始化器:从其他对象赋值属性/字段到新对象中的一种简化的创建表达式形式

例如对于之前的电商订单模型:

  • Order——OrderId、Customer、Items
  • Customer——Name、Address
  • OrderItem——ItemId、Quantity
var flattenedItem = new
{
    order.OrderId,
    CustomerName = customer.Name,
    customer.Address,
    item.ItemId,
    item.Quantity
}
var flattenedItem = new
{
    OrderId = order.OrderId,
    CustomerName = customer.Name,
    Address = customer.Address,
    ItemId = item.ItemId,
    Quantity = item.Quantity
}

img

3.4.2 编译器生成类型

虽然源码中没有出现匿名类型的名称,但编译器需要为它生成一个类型

匿名类型的特点(采用微软C#编译器时)

  • 它是一个类(保证)。
  • 其基类是object(保证)。
  • 该类是密封的(不保证,虽然非密封的类并没有什么优势)。
  • 属性是只读的(保证)。
  • 构造器的参数名称与属性名称保持一致(不保证,有时对于反射有用)。
  • 对于程序集是internal的(不保证,在处理动态类型时会比较棘手)。
  • 该类会覆盖GetHashCode()Equals()方法:两个匿名类型只有在所有属性都等价的情况下才等价。(可以正常处理null值。)只保证会覆盖这两个方法,但不保证散列值的计算方式。
  • 覆盖并完善ToString()方法,用于呈现各属性名称及其对应值。这一点不保证,但对于问题诊断来说作用重大。
  • 该类型为泛型类,其类型形参会应用于每一个属性。具有相同属性名称但属性类型不同的匿名类型,会使用相同的泛型类型,但拥有不同的类型实参。这一点不保证,不同编译器的实现方式不同。
  • 如果两个匿名对象创建表达式使用相同的属性名称,具有相同的属性类型以及属性顺序,并且在同一个程序集中,那么这两个对象的类型相同。

还有一点是使用匿名类型创建隐式类型数组

var players = new[]
{
    new { Name = "Priti", Score = 6000 },
    new { Name = "Chris", Score = 7000 }
}

请注意,两个匿名类型若要类型相同,那么两个匿名对象创建表达式中的属性名称、属性类型以及属性的顺序都必须完全一致。

var players = new[]
{
    new { Name = "Priti", Score = 6000 },
    new { Score = 7000, Name = "Chris" }  // 错误:顺序不一致
    new { Name = "Amanda", Score = 8000 },
}

3.4.3 匿名类型的局限性

局部化:指某个数据形态的适用范围限制在特定方法中。匿名类型实现数据的局部化是其优点也是缺点

匿名类型不提供任何数据封装(校验、行为…)

游戏开发中的使用场景:

何时使用 何时避开
方法内部的中间计算结果 Unity Inspector / ScriptableObject 相关
LINQ 数据投影和过滤 跨程序集传递或热更新边界
编辑器工具和数据验证脚本 高频执行的 Update 循环
表达式树中的动态查询构建 需要公开的 API 接口
不需要序列化的一次性数据 需要持久化存储的任何数据

3.5 lambda表达式

匿名函数:指代匿名方法和lambda表达式

3.5.1 lambda表达式语法简介

基本语法:

参数列表 => 主体

示例:

Action<string> action = (string message) =>
{
    Console.WriteLine("In delegagte: {0}", message);
};

action("Messagge");

简化后:

Action<string> action = 
    message => Console.WriteLine("In delegagte: {0}", message);

action("Messagge");

3.5.2 捕获变量

变量捕获过程可理解为:匿名函数对代码上下文及变量的“引用”,而非创建时的“快照”“

在 lambda 表达式中,可以像使用普通方法那样任意使用变量。
这些变量可以是静态字段、实例字段(如果在实例方法中编写 lambda 表达式)this 变量、方法参数或者局部变量。这些都属于捕获变量的范畴,因为它们都定义在 lambda 表达式所在直接上下文之外。那些 lambda 表达式自带的参数或者定义在 lambda 表达式内部的局部变量,则不属于捕获变量。

class Program
{
    static void Main()
    {
        // 其他代码
        var demo = new CapturedVariablesDemo();
        Action<string> action = demo.CreateAction("method argument");
        action("lambda argument");
    }
}

class CapturedVariablesDemo
{
    private string instanceField = "实例字段";

    public Action<string> CreateAction(string methodParameter)
    {
        string methodLocal = "method local";
        string uncaptured = "uncaptured local";

        Action<string> action = lambdaParameter =>
        {
            string lambdaLocal = "lambda local";
            Console.WriteLine("Instance field: {0}", instanceField);
            Console.WriteLine("Method parameter: {0}", methodParameter);
            Console.WriteLine("Method local: {0}", methodLocal);
            Console.WriteLine("Lambda parameter: {0}", lambdaParameter);
            Console.WriteLine("Lambda local: {0}", lambdaLocal);
        };
        
        methodLocal = "modified method local";
        return action;
    }
}

🍀 需要重点关注的是,这些 lambda 表达式捕获的是这些变量本身,而不是委托创建时这些变量的值副本

那么,在委托定义后 到 委托调用前,这段时期修改任何一个捕获变量,都会在输出结果中体现出来

编译器如何保证这些变量在委托调用时依然可用呢?

1. 通过生成类来实现捕获变量

  • 如果没有捕获任何变量,那么编译器可以创建一个静态方法,不需要额外的上下文。
  • 如果仅捕获了实例字段,那么编译器可以创建一个实例方法。在这种情况下,捕获1个实例字段和捕获100个没有什么差别,只需一个 this 便可都可以访问到。
  • 如果有局部变量或参数被捕获,编译器会创建一个私有的嵌套类来保存上下文信息,然后在当前类中创建一个实例方法来容纳原 lambda 表达式的内容。原先包含 lambda 表达式的方法会被修改为使用嵌套类来访问捕获变量。

2. 局部变量的多次实例化

static List<Action> CreateActions()
{
    List<Action> actions = new List<Action>();
    for (int i = 0; i < 5; i++)
    {
        string text = string.Format("message {0}", i);  // 在循环内部声明局部变量
        actions.Add(() => Console.WriteLine(text));  // 在 lambda 表达式中捕获该变量
    }
    return actions;
}

// 其他代码
List<Action> actions = CreateActions();
foreach(Action action in actions)
{
    action();
}

这里是 5 个完全独立的 text 变量被分别捕获

message 0
message 1
message 2
message 3
message 4

编译器在每次初始化都创建一个不同的生成类型实例,CreateAction会转译为如下形式

private class LambdaContext
{
    public string text;

    public void Method()
    {
        Console.WriteLine(text);
    }
}

static List<Action> CreateActions()
{
    var actions = new List<Action>();
    for (int i = 0; i < 5; i++)
    {
        var context = new LambdaContext();      // 为每次循环都创建一个新的上下文对象
        context.text = string.Format("message {0}", i);
        actions.Add(context.Method);        // 使用上下文创建一个 action
    }
    return actions;
}

3. 多个作用域下的变量捕获

class Program
{
    static void Main()
    {
        List<Action> actions = CreateCountingActions();
        // 每个委托调用两次
        actions[0]();
        actions[0]();
        actions[1]();
        actions[1]();
    }

    static List<Action> CreateCountingActions()
    {
        List<Action> actions = new List<Action>();
        int outerCounter = 0;                               // 两个委托捕获同一个变量
        for (int i = 0; i < 2; i++)
        {
            int innerCounter = 0;                           // 每次循环都创建一个新变量
            Action action = () =>
            {
                Console.WriteLine(
                    "Outer: {0}, Inner: {1}",
                    outerCounter, innerCounter);
                outerCounter++;
                innerCounter++;
            };
            actions.Add(action);
        }
        return actions;
    }
}

Outer: 0, Inner: 0
Outer: 1, Inner: 1
Outer: 2, Inner: 0
Outer: 3, Inner: 1

这里outerCounter变量被两个委托共用,innerCounter为两个委托分别所有

每个委托都需要各自的上下文,各自的上下文又需要指向一个公共的上下文。编译器创建两个私有嵌套类来处理这种情况

class Program
{
    static void Main()
    {
        
    }
    
    /// <summary>
    /// 外层作用域的上下文
    /// </summary>
    private class OuterContext
    {
        public int outerCounter;
    }
    
    private class InnerContext
    {
        // 包含外层上下文引用的内层作用域上下文
        public OuterContext outerContext;
        public int innerCounter;

        public void Method()        // 用于创建委托的方法
        {
            Console.WriteLine(
                "Outer: {0}; Inner: {1}",
                outerContext.outerCounter, innerCounter);
            outerContext.outerCounter++;
            innerCounter++;
        }
    }

    static List<Action> CreateCountingActions()
    {
        List<Action> actions = new List<Action>();
        OuterContext outerContext = new OuterContext();     // 创建一个外层上下文
        outerContext.outerCounter = 0;
        for (int i = 0; i < 2; i++)
        {
            // 每次循环都创建一个内层上下文
            InnerContext innerContext = new InnerContext();
            innerContext.outerContext = outerContext;
            innerContext.innerCounter = 0;
            Action action = innerContext.Method;
            actions.Add(action);
        }
        return actions;
    }
}

🍀 如果在性能敏感的代码中使用 lambda 表达式,那么需要注意可能会因为变量捕获而创建过多对象,从而影响性能。

3.5.3 表达式树

表达式树是将代码按照数据来表示的一种形式

这里联想到了unity常用的持久化方案,如json、xml、SQLite等。xml用的比较少,json适合轻量和快速开发场景,sqlite适合数据庞大/查询复杂的场景。
以及动态的伤害计算公式如何实现?

委托的作用是提供可运行的代码,而表达式树的作用是提供可查看的代码,这有点类似于反射(reflection)机制。

using System.Linq.Expressions;

class Program
{
    static void Main()
    {
        Expression<Func<int, int, int>> adder = (x, y) => x + y;
        Console.WriteLine(adder);
    }
}

这段代码表明:代码是可以进行执行期检查的。这就是表达式树的所有关键所在。

理解:

拆开来看,由Expression<TDelegate>Func<int, int, int>组成,Func的返回值类型由最后一个参数指定

using System.Linq.Expressions;

class Program
{
    static void Main()
    {
        ParameterExpression xParameter = Expression.Parameter(typeof(int), "x");
        ParameterExpression yParameter = Expression.Parameter(typeof(int), "y");
        Expression body = Expression.Add(xParameter, yParameter);
        ParameterExpression[] parameters = new[] { xParameter, yParameter };
        Expression<Func<int, int, int>> adder = Expression.Lambda<Func<int, int, int>>(body, parameters);
        Console.WriteLine(adder);
    }
}

1. 转换表达式树的局限性

只有拥有表达式主体的 lambda 表达式才能转换成表达式树

Expression<Func<int, int, int>> adder = (x, y) => { return x + y; };

lambda 表达式不能使用赋值运算符,也不能使用C#4的动态类型和C#5的异步。(虽然对象初始化器和集合初始化器也用到了=,但是在其上下文中并不算赋值运算符。)

2. 将表达式树编译成委托

using System.Linq.Expressions;

class Program
{
    static void Main()
    {
        Expression<Func<int, int, int>> adder = (x, y) => x + y;
        Func<int, int, int> executableAdder = adder.Compile();    // 表达式树编译成委托
        Console.WriteLine(executableAdder(2, 3));    // 正常调用委托
    }
}


3.6 扩展方法

假设有如下方法调用

ExampleClass.Method(x, y);

改成扩展方法后,就可以以实例方法的调用形式来调用

x.Method(y);

在游戏开发中,对于引擎类(大多为sealed/无法继承的密封类)可以无需修改源码拓展方法;另外对于一些频繁使用的数据操作,使用扩展方法可以更直观

3.6.1 声明扩展方法

必要:第一个参数关键字要有this;扩展方法必须声明在一个非嵌套、非泛型的静态类中;扩展方法所在类不能是泛型类,但扩展方法本身可以是泛型方法

namespace NodaTime.Extensions
{
    public static class DateTimeOffsetExtensions
    {
        public static Instant ToInstant(this DateTimeOffset dateTimeOffset)
        {
            return Instant.FromDateTimeOffset(dateTimeOffset);
        }
    }
}

编译器干的事:给扩展方法和其所在类添加[Extension]特性

3.6.2 调用扩展方法

编译器查找扩展方法优先级顺序

  1. 存在同名的普通实例方法,优先选择该实例方法调用
  2. 查找扩展方法调用代码所在的命名空间以及所有using指令指定的命名空间

例:

using NodaTime.Extension;  // 引入 NodaTime.Extensions 命名空间
using System;

namespace CSharpInDepth.Chapter03
{
    class ExtensionMethodInvocation
    {
        static void Main()
        {
            var currentInstant = DateTimeOffset.UtcNow.ToInstant();  // 调用扩展方法
            Console.WriteLine(currentInstant);
        }
    }
}

编译器会从以下位置查找扩展方法:

  • CSharpInDepth.ChapterO3命名空间下的静态类;
  • CSharpInDepth命名空间下的静态类;
  • 全局命名空间下的静态类;
  • using指令指定的命名空间下的静态类(例如using System这样的指向命名空间的命令);
  • (只在C#6中)using static指定的静态类

img

3.6.3 扩展方法的链式调用

class Program
{
    static void Main()
    {
        string[] words = { "keys", "coat", "laptop", "bottle" };  // 简单的数据源
        // 过滤、排序、转换
        IEnumerable<string> query = words
            .Where(word => word.Length > 4)
            .OrderBy(word => word)
            .Select(word => word.ToUpper());

        // 打印结果
        foreach (string word in query)
        {
            Console.WriteLine(word);
        }
    }
}

由于LINQ中存在延迟和优化策略,我们很难知道具体何时会执行什么操作,但代码的阅读顺序和执行顺序是一致的。

class Program
{
    static void Main()
    {
        string[] words = { "keys", "coat", "laptop", "bottle" };
        IEnumerable<string> query =
            Enumerable.Select(
                Enumerable.OrderBy(
                    Enumerable.Where(words, word => word.Length > 4),
                    word => word.Length),
                word => word.ToUpper());
    }
}


3.7 查询表达式

对于上面使用链式调用的代码,使用查询表达式改写如下

IEnumerable<string> query = from word in words
                            where word.Length > 4
                            orderby word
                            select word.ToUpper();

可以感受到非常简洁,在查询表达式中,wordfrom子句中作为一个范围变量出现

3.7.1 从 C# 到 C# 的查询表达式转换

  • 查询表达式 vs 之前特性(如lambda捕获变量)的转译差异

    之前讲解捕获变量时,只是展示编译器生成的“等效”C#代码(描述结果,非实际生成)。

    查询表达式的语言规范直接将其定义为语法转译,转译发生在绑定或重载决议之前

  • 转译的具体形式

    转译结果通常变成扩展方法调用,但语言规范不强制要求必须是扩展方法调用;也可以是实例方法调用,或调用 SelectWhere 等属性返回的委托

  • API 要求

    规范规定:使用查询表达式时,相关类型必须提供某些方法(如 SelectOrderByWhere 等),但不要求提供全部方法(例如可以没有 join 子句对应的支持)。

3.7.2 范围变量和隐性标识符

  • 范围变量

    查询表达式引入的特殊变量,充当查询语句中每个子句的输入。

    • 例如 from word in words 中的 word 就是范围变量。
    • 还可以通过 let 子句引入新的范围变量(如 let length = word.Length)。
from word in words
let length = word.length
where length > 4
...
  • 多范围变量的表示(转译机制)

    当存在多个范围变量(如 wordlength)时,编译器会将原始序列转换为匿名类型的“键-值对”序列。

    • 示例转译:words.Select(word => new { word, length = word.Length })
    • 后续子句通过匿名类型实例(如 tmp)访问 .word.length
  • 隐形标识符

    转译时生成的临时参数名称(如 tmp),语言规范中用 * 表示。

    • 该名称在编写查询时不可见,故称“隐形标识符”。
    • 规范不规定参数的具体命名。

3.7.3 选择使用哪种LINQ语法

  • 查询表达式的局限性

    必须以 from 开头,以 selectgroup by 结尾。对于简单过滤,写法显得笨拙。

    • 示例对比:
      • 查询表达式:from word in words where word.Length > 4 select word
      • 方法语法:words.Where(word => word.Length > 4)
    • 两者编译结果相同,简单场景优先选择方法语法
  • 方法语法的优势(复杂查询时)

    • LINQ 提供的很多方法没有对应的查询表达式语法(如带索引的 Select/Where 重载)。
    • 需要末尾调用 ToList() 等方法时,方法语法直接追加调用,而查询表达式需要圆括号括起整个表达式
  • 查询表达式的优势

    • 需要编译器处理隐形标识符(多范围变量)时,查询表达式更简洁。
    • 手动构建匿名类型再拆解很烦琐,查询表达式自动完成。
  • 建议

    两种语法都要掌握。舍弃任何一种都可能丧失提升代码可读性的机会。

术语说明

  • 非查询表达式的语法统称为方法语法(其他常见称呼:点式语法、流式语法、lambda语法等,无需刻意区分)。

3.8 终极形态:LINQ

Unity 有常用的ZLinq插件,相比linq来说无gc分配。使用上只需增加一个AsValueEnumerable方法,其他与传统LINQ相同

  • 示例查询(Entity Framework)

    var products = from product in dbContext.Products
        where product.StockCount > 0
        orderby product.Price descending
        select new { product.Name, product.Price };
    
  • 该查询用到的C# 3特性及作用

    1. 匿名类型(含投射初始化器)—— 只选择 NamePrice 两个属性。
    2. var 声明 —— 因为匿名类型无法显式声明类型。
    3. 查询表达式 —— 复杂查询时能事半功倍(本例可改用方法语法,但查询表达式更清晰)。
    4. lambda 表达式 —— 作为查询表达式转译后的基础。
    5. 扩展方法 —— 使转译后的查询能通过 Queryable 类实现(dbContext.Products 实现 IQueryable<T>)。
    6. 表达式树 —— 将查询逻辑以数据形式传给 LINQ 提供器,转换成 SQL 交给数据库执行。
  • 总结:各特性缺一不可

    • 缺少任意一个特性,LINQ 的实用性都会大打折扣。
    • 虽可用内存集合代替表达式树、专用类代替扩展方法、简单查询不用查询表达式,但这些特性组合在一起产生了全新的效果
posted @ 2026-07-28 18:59  绘星tsuki  阅读(2)  评论(0)    收藏  举报