第4章 C#的高级特性

第4章 C#的高级特性

4.1 委托

4.1.2 多播委托

对值为 null 的委托变量进行 + ​或 += ​操作,等价于为变量指定一个新值:

SomeDelegate d = null;
d += SomeMethod1;    // 等价于 d = SomeMethod1

委托是 不可变 的,因此调用 += ​和 -= ​的实质是 创建一个新的委托实例 ,并 把它赋值给已有变量

如果一个多播委托拥有非 void​ ​的返回类型,则调用者将从 最后一个触发的 方法接收返回值。前面的方法仍然调用,但是返回值都会​** 被丢弃 **。大部分调用多播委托的情况都会返回 void​ ​类型,因此这个细小的差异就没有了。

C7.0 核心技术指南 第7版.pdf - p178 - C7.0 核心技术指南 第 7 版-P178-20240125212957

4.1.3 实例目标方法和静态目标方法

委托对象持有的不仅是方法,还包括该方法的所属实例。该实例由 System.Delegate ​类的 Target ​属性持有:

public delegate void ProgressReporter (int percentComplete);

static void Main() {
	X x = new X();
	ProgressReporter p = x.InstanceProgress;
	p(99);                                 // 99
	Console.WriteLine (p.Target == x);     // True
	Console.WriteLine (p.Method);          // Void InstanceProgress(Int32)
}

class X {
	public void InstanceProgress (int percentComplete) => Console.WriteLine (percentComplete);
}

如果委托引用的是一个 静态 方法,该属性值为 null​。

经测试,对于多播委托,Target ​属性持有的是最后一个方法对应的实例

4.1.7 委托的兼容性

4.1.7.1 类型的兼容性

两个委托即使签名相似,委托类型 也互不 兼容:

delegate void D1();
delegate void D2();

D1 d1 = Method1;
D2 d2 = d1;      // Compile-time error

但是允许下面的写法:

D1 d1 = Method1;
D2 d2 = new D2 (d1);	// Legal

如果委托实例指向 相同的目标方法 ,则认为它们是等价的。如下代码将输出“ TrueFalse ”:

D d1 = Method1;
D d2 = Method1;
D d3 = new D(d1);
Console.WriteLine (d1 == d2);
Console.WriteLine (d1 == d3);

上述代码中,d1​、d2​ 的 Target​ 为当前实例,d3​ 的 Target​ 为 d1​,单从这点看,它们就不相同

如果多播委托 按照相同的顺序引用相同的方法 ,则认为它们是等价的。

4.1.7.2 参数的兼容性

委托也可以有比它的目标方法参数类型更具体的参数类型,这称为 变。例如:

delegate void StringAction (string s);

static void Main() {
	StringAction sa = new StringAction (ActOnObject);
	sa ("hello");
}

static void ActOnObject (object o) => Console.WriteLine (o);   // hello

可以看到,ActOnObject(object) ​是 StringAction(string) 类,却将 ActOnObject(object) ​赋值给 StringAction(string)​,因此是 变。

4.1.7.3 返回类型的兼容性

委托的目标方法可能返回比委托声明的返回值类型更加特定的返回值类型,这称为 变。例如:

delegate object ObjectRetriever();

static void Main() {
	ObjectRetriever o = new ObjectRetriever (RetriveString);
	object result = o();
	Console.WriteLine (result);      // hello
}

static string RetriveString() => "hello";

ObjectRetriever​​ ​期望返回一个 object​。但若返回 object​​ ​ 类也是可以的,这是因为委托的返回类型是 变的。

4.1.7.4 泛型委托类型的参数协变

从 C#4 开始,委托也支持泛型类型的逆变和协变。

如果我们要定义一个泛型委托类型,那么最好参考如下的准则:

  • 将只用于 返回值类型 的类型参数标记为协变(out)
  • 将只用于 参数 的类型参数标记为逆变(in)

如下委托支持 变:

Func<string> x = () => "Hello, world";
Func<object> y = x;

如下委托支持 变:

Action<object> x2 = o => Console.WriteLine (o);
Action<string> y2 = x2;

4.2 事件

4.2.1 标准事件模式

.NET Framework 为事件编程定义了一个标准模式,以保持框架和用户代码的一致性。

事件信息类:EventArgs

预定义类,它要求:

  1. 所有事件信息类都是它的 子类
  2. EventArgs ​子类应当根据 包含它的信息 来命名(而非根据使用它的事件命名);
  3. 通过 属性只读字段将 数据暴漏给外界。
事件的委托:EventHandler

事件的委托要遵循三条规则:

  1. 委托必须以 void ​作为返回值。

  2. 委托必须接受两个参数。

    1. 第一个参数是 object ​类型,表明事件的 广播者
    2. 第二个参数是 EventArgs ​的子类,包含了 需要传递的信息
  3. 委托的名称必须以 EventHandler ​ 结尾。

框架定义了一个名为 System.EventHandler<> ​的泛型委托,满足上述条件:

public delegate void EventHandler(object source, TEventArgs e) where TEventArgs : EventArgs;
事件实例和触发事件

完成上述内容后,要定义事件实例了。这里使用泛型的 EventHandler ​委托:

public event EventHandler<PriceChangedEventArgs> PriceChanged;

最后,我们需要编写一个 protected ​ ​的 方法来触发事件。方法名必须和 事件名称 一致,以 On ​ ​作为前缀,并接收唯一的 EventArgs ​ ​参数:

protected virtual void OnPriceChanged (PriceChangedEventArgs e) {
	PriceChanged?.Invoke (this, e);
}

C7.0 核心技术指南 第7版.pdf - p188 - C7.0 核心技术指南 第 7 版-P188-20240126124301

完整代码

这样就提供了一个子类可以调用或重写事件的关键点(假如不是密封类的话)。以下是完整的例子:

public class PriceChangedEventArgs : EventArgs
{
	public readonly decimal LastPrice;
	public readonly decimal NewPrice;

	public PriceChangedEventArgs (decimal lastPrice, decimal newPrice)
	{
		LastPrice = lastPrice; NewPrice = newPrice;
	}
}

public class Stock
{
	string symbol;
	decimal price;

	public Stock (string symbol) {this.symbol = symbol;}

	public event EventHandler<PriceChangedEventArgs> PriceChanged;

	protected virtual void OnPriceChanged (PriceChangedEventArgs e)
	{
		PriceChanged?.Invoke (this, e);
	}

	public decimal Price
	{
		get { return price; }
		set
		{
			if (price == value) return;
			decimal oldPrice = price;
			price = value;
			OnPriceChanged (new PriceChangedEventArgs (oldPrice, price));
		}
	}
}

static void Main()
{
	Stock stock = new Stock ("THPW");
	stock.Price = 27.10M;
	// Register with the PriceChanged event
	stock.PriceChanged += stock_PriceChanged;
	stock.Price = 31.59M;
}

static void stock_PriceChanged (object sender, PriceChangedEventArgs e)
{
	if ((e.NewPrice - e.LastPrice) / e.LastPrice > 0.1M)
		Console.WriteLine ("Alert, 10% stock price increase!");
}

4.2.2 事件访问器

事件访问器是对事件的 += ​和 -= ​功能的实现。默认情况下,访问器由编译器隐式实现。

考虑如下的声明:

public event EventHandler PriceChanged;

编译器将其转化为:

  • 一个 私有委托 字段。
  • 一对 公有事件访问器 函数( add_PriceChanged ​​ ​和 remove_PriceChanged ​),它们将 +=​​ ​和 -=​​ ​操作转向了 私有委托字段

自定义事件访问器

我们也可以显式定义事件访问器来替代这个过程。以下是 PriceChanged​ 事件的手动实现:

private EventHandler _priceChanged;         // Declare a private delegate
public event EventHandler PriceChanged
{
    add    { _priceChanged += value; }		// Explicit accessor
    remove { _priceChanged -= value; }		// Explicit accessor
}

本例从功能上和 C#的默认访问器实现是等价的(但是 C#还使用了无锁的比较并交换算法,保证了在更新委托时的线程安全性,请参见 Threading in C# - Free E-book (albahari.com))。有了自定义事件访问器,C#就不会生成 默认的字段访问器逻辑

自定义事件访问器的常见情形

显式定义事件访问器,可以在委托的存储和访问上进行更复杂的操作。这主要有三种情形:

当前事件访问器仅仅是广播事件的类的 中继器
public class RelayClass { 
    private RealClass realClass = new RealClass();

    public event EventHandler MyEvent {
        add    { realClass.MyEvent += value; }
        remove { realClass.MyEvent -= value; }
    }
}

public class RealClass {
    public event EventHandler MyEvent;
}
当类定义了大量的事件,而大部分事件 有很少的订阅者

例如 Windows 控件。在这种情况下,最好在一个字典中存储订阅者的委托实例,仅在事件订阅时才会分配资源:

public class EventContainer {
    private Dictionary<string, EventHandler> eventHandlers = new Dictionary<string, EventHandler>();

    public event EventHandler RarelyUsedEvent {
        add    { eventHandlers["RarelyUsedEvent"] += value; }
        remove { eventHandlers["RarelyUsedEvent"] -= value; }
    }

    // 可以为其他事件以类似方式定义访问器
}
** 显式 **实现接口定义的事件时。

此时显式事件访问器是必需的。这在实现接口的同时需要保持类的成员封装性时特别有用。

public interface IFoo { event EventHandler Ev; }

class Foo : IFoo {
	private EventHandler ev;

	event EventHandler IFoo.Ev {
		add    { ev += value; }
		remove { ev -= value; }
	}
}

C7.0 核心技术指南 第7版.pdf - p191 - C7.0 核心技术指南 第 7 版-P191-20240126231242

4.2.3 事件的修饰符

和方法类似,事件可以是:

  1. 虚的(virtual)
  2. 抽象的(abstract)
  3. 被重写(overridden)
  4. 密封的(sealed)
  5. 静态的
public class Foo {
    public static event EventHandler<EventArgs> StaticEvent;
    public virtual event EventHandler<EventArgs> VirtualEvent;
}

4.3 Lambda 表达式

Lambda 表达式是一种可以替代委托实例的匿名方法。编译器会立即将 Lambda 表达式转换为以下两种形式之一:

  • 一个 委托实例

  • 一个类型为 Expression<TDelegate> ​ ​的 表达式树

    该表达式树将 Lambda 表达式内部的代码表现为一个可遍历的对象模型,因此 Lambda 表达式的解释可以延迟到运行时(请参见8.10 构建查询表达式

C7.0 核心技术指南 第7版.pdf - p191 - C7.0 核心技术指南 第 7 版-P191-20240126231650

Lambda 的表现形式

Lambda 表达式拥有以下的形式:

(parameters) => expression-or-statement-block

为了方便,在只有一个可推测类型的参数时, 参数 可以省略 小括号

Lambda 的参数和表达式有:

  • 参数:每个参数对应委托的参数

  • 表达式:类型对应委托的类型(可以是 void)

    函数体可以是:

    • 表达式(expression)
    x => x * x;
    
    • 语句(statement)
    x => { return x * x; }
    

4.3.1 显式指定 Lambda 参数的类型

编译器通常可以根据上下文推断出 Lambda 表达式的类型,但是当无法推断的时候则必须显式指定每一个参数的类型。假设现有如下泛型方法:

void Foo<T> (T x)         {}
void Bar<T> (Action<T> a) {}

以下代码无法通过编译,因为编译器 无法推断 x 的类型

Bar(x => Foo(x));    // x是什么类型?

有两种方式解决该问题:

  1. 显式指定 x 的类型:
Bar((int x) => Foo(x));
  1. 直接指定泛型方法接收的参数类型:
Bar<int> (x => Foo(x));
// or
Bar<int> (Foo);

4.3.2 捕获外部变量

Lambda 表达式可以引用方法内定义的局部变量和方法参数(外部变量,outer variables)。

捕获的变量会在 真正调用委托 时赋值,而非 捕获 时赋值:

int factor = 2;
Func<int, int> multiplier = n => n * factor;
Console.WriteLine (multiplier (3));           // 6

factor = 10;
Console.WriteLine (multiplier (3));           // 30

Lambda 表达式 可以更新捕获的变量的值:

int seed = 0;
Func<int> natural = () => seed++;
Console.WriteLine (natural());           // 0
Console.WriteLine (natural());           // 1
Console.WriteLine (seed);                // 2
捕获变量的生命周期

捕获变量的生命周期延伸到了和 委托 的生命周期一致。在以下例子中,局部变量 seed​ 本应该在 Natural​ 执行完毕后 从作用域中消失 ,但由于 seed​ 被捕获,因此其生命周期已经和 捕获它的委托 Natural ​ 一致了,以下代码将输出 “0”和“1”

static Func<int> Natural() {
	int seed = 0;
	return () => seed++;	  // Returns a closure
}

static void Main() {
	Func<int> natural = Natural();
	Console.WriteLine (natural());
	Console.WriteLine (natural());
}

在 Lambda 表达式内实例化的局部变量在每一次调用委托实例期间都是唯一的。如果我们把上述例子改成在 Lambda 表达式内实例化 seed​,则程序的结果将与之前不同,以下代码将输出“ 0”和“0 ”:

static Func<int> Natural() {  
	return() => { int seed = 0; return seed++; };
}

static void Main() {
	Func<int> natural = Natural();
	Console.WriteLine (natural());
	Console.WriteLine (natural());
}

C7.0 核心技术指南 第7版.pdf - p194 - C7.0 核心技术指南 第 7 版-P194-20240127132425

捕获迭代变量

for 循环中的迭代遍历,C# 会认为该变量是在循环体外定义的,如下代码将输出“ 333 ”:

Action[] actions = new Action[3];

for (int i = 0; i < 3; i++)
	actions [i] = () => Console.Write (i);

foreach (Action a in actions) a();

如果想要输出“012”,则循环变量要定义为循环内的局部变量:

Action[] actions = new Action[3];

for (int i = 0; i < 3; i++) {
	int loopScopedi = i;
	actions [i] = () => Console.Write (loopScopedi);
}

foreach (Action a in actions) a();

4.3.3 Lambda 表达式和局部方法的对比

局部方法相较 Lambda 有如下优势:

  1. 局部方法可以很方便的实现 递归

  2. 局部方法避免了定义 杂乱的委托 类型;

  3. 局部方法开销 更小

    1. 它不需要间接使用委托(委托会消耗更多的 CPU 时钟周期并使用更多的内存)
    2. 访问局部变量时不需要编译器将捕获的变量放到一个隐藏的类中。

但是局部方法不能像委托那样作为 参数 传递给 方法

4.4 匿名方法

匿名方法在 C#2 引入,后由 C#3 的 Lambda 代替。匿名方法类似于 Lambda,但是没有如下特性:

  1. 隐式类型 的参数;
  2. 表达式 语法;

匿名方法必须是一个语句块
3. 在赋值给 Expression<T> ​时将其编译为 表达式树 的能力。

编写方式

匿名方法的编写方式如下:

delegate int Transformer (int i);

Transformer sqr = delegate (int x) { return x * x; };
Console.WriteLine (sqr(3));                     // 9

它等价于如下 Lambda 表达式:

Transformer sqr = (int x) => { return x * x; };
// or
Transformer sqr = x => x * x;

C7.0 核心技术指南 第7版.pdf - p197 - C7.0 核心技术指南 第 7 版-P197-20240128203532

4.5 try 语句和异常

4.5.1 catch 子句

catch 块如果不需要访问异常内容,可以捕获异常但 不指定变量

catch (OverflowException){
    ...
}

甚至可以同时忽略异常类型和变量,此时将 捕获所有异常

catch { ... }

异常筛选器(C#6)

异常筛选器(exception filter)于 C#6 引入。通过异常筛选器我们可以 重复捕获同类型异常

catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{ ... }
catch (WebException ex) when (ex.Status == WebExceptionStatus.NameResolutionFailure)
{ ... }

when 子句中的布尔表达式可以包含 副作用 ,例如调用一个方法记录诊断所需的异常的日志。

4.5.2 finally 块

finally 块会在以下情况执行:

  1. try 块执行结束后。
  2. catch 块执行结束后。
  3. 使用跳转语句(例如 return、goto),离开了 try 块。

using 语句

finally 块中调用 Dispose​ ​方法是贯穿.NET Framework 的标准约定,例如:

StreamReader reader = null;    // In System.IO namespace
try {
	reader = File.OpenText ("file.txt");
	if (reader.EndOfStream) return;
	Console.WriteLine (reader.ReadToEnd());
}
finally {
	if (reader != null) reader.Dispose();
}

上述内容可以用 using 语句替代:

using (StreamReader reader = File.OpenText ("file.txt")) {
	if (reader.EndOfStream) return;
	Console.WriteLine (reader.ReadToEnd());
}

4.5.3 抛出异常

4.5.3.1 throw 表达式(C#7)

1.8.1.8 throw 表达式

在 C#7 之前,throw 一直是一个 语句(Statement) 。现在它也可以作为 表达式(Expression) 出现在表达式体函数中:

public string Foo() => throw new NotImplementedException();

throw 表达式也可以出现在三目运算符中:

string Capitalize(string value) =>
    value == null ? throw new ArgumentException("value") :
    value == "" ? "" :
    char.ToUpper(value[0]) + value.Substring(1);

4.6 可枚举类型和迭代器

4.6.1 可枚举类型

foreach 语句用来在可枚举(enumerable)的对象上执行迭代操作。可枚举的对象可以是:

  • IEnumerable(<T>) ​ 的实现
  • 具有名为 GetEnumerator ​ 的方法并且返回值是一个 枚举器(IEnumerator 对象

如果迭代器实现了 IDisposable ​​,则 foreach 语句也会起到 using 语句的作用,隐式销毁枚举器对象。

4.6.2 集合的初始化器

集合初始化器要求可枚举对象实现 IEnumerable ​ ​接口,并且有 Add ​ 方法(可调用,且带有适当数量的参数)。

通过它仅需一个简单的步骤就能实例化并填充可枚举对象(含字典):

List<int> list = new List<int> {1, 2, 3};
var dict = new Dictionary<int, string>() {
    { 5, "five" },
    { 10, "ten" }
};

或者更加简洁的写为:

1.8.2.4 索引初始化器(index initializer)

参见4.6.2 集合的初始化器,可以一次性初始化具有索引器的任意类型:

var dict = new Dictionary<int, string>()
{
    [3] = "three",
    [10] = "ten"
};

索引初始化器的写法不仅适用于字典,还适用于任何具有 索引 器的类型。

Notice

集合初始化器和索引初始化器是两回事。集合初始化器要求实现 IEnumerable​ 接口;索引初始化器要求实现索引器。

4.6.3 迭代器

迭代器是枚举器的生产者,它的编写方式如下:

static IEnumerable<int> Fibs (int fibCount) {
	for (int i = 0, prevFib = 1, curFib = 1; i < fibCount; i++) {
		yield return prevFib;
		int newFib = prevFib + curFib;
		prevFib = curFib;
		curFib = newFib;
	}
}

return​ ​语句表示“这是该方法的返回值”,而 yield return​ ​语句则表示“这是 当前枚举器产生的下一个元素 ”。在每条 yield​ ​语句中,控制都返回给调用者,但是必须同时维护调用者的状态,以便调用者枚举下一个元素的时候,方法能够继续执行。该状态的生命周期是与枚举器绑定的。当调用者枚举结束之后,该状态就可以被释放。

C7.0 核心技术指南 第7版.pdf - p208 - C7.0 核心技术指南 第 7 版-P208-20240128221450

4.6.4 迭代器语义

迭代器是包含一个或者多个 yield 语句的方法属性或者索引器。迭代器必须返回以下四个接口之一(否则编译器会产生相应错误):

  • 可枚举接口(Enumerable interfaces​)

    1. System.Collections.IEnumerable
    2. System.Collections.Generic.IEnumerable<T>
  • 枚举器接口(Enumerator interfaces​)

    1. System.Collections.IEnumerator
    2. System.Collections.Generic.IEnumerator<T>

迭代器具有不同的语义,取决于返回的是可枚举接口还是枚举器接口。详见7.1 枚举

允许使用多个 yield statements,例如:

static IEnumerable<string> Foo (bool breakEarly) {
	yield return "One";
	yield return "Two";
	yield return "Three";
}

4.6.4.1 yield break 语句

yield break 语句表明迭代器块不再返回更多的元素而是提前退出:

foreach (string s in Foo (true))
    Console.WriteLine(s);

static IEnumerable<string> Foo (bool breakEarly) {
	yield return "One";
	yield return "Two";

	if (breakEarly)
		yield break;

	yield return "Three";
}

C7.0 核心技术指南 第7版.pdf - p210 - C7.0 核心技术指南 第 7 版-P210-20240128225216

4.6.4.2 迭代器和 try/catch/finally 语句块

yield return 不能出现在:

  1. try-catch try 语句块中
  2. catch 块中
  3. finally 块中

可以出现在:

  1. try-finally try语句块中

使用 try-finally ​块,语句的执行可能有如下几种情况:

  1. 枚举器正常结束,正常执行至 finally​ 块。
  2. 枚举提前结束(被销毁时),将会执行 finally ​块。

不过,显式使用枚举器时(即不使用 foreach​),可能会提前结束枚举而不销毁枚举器,从而绕过 finally​ 块的执行。我们可以将枚举器的使用显式包裹在 using ​ 语句中来避免上述错误:

using (var enumerator = sequence.GetEnumerator())
    if(enumerator.MoveNext())
        firstElement = enumerator.Current;

4.6.5 组合序列

迭代器可以高度组合。我们可以扩展前面的示例,只输出偶数斐波那契数列:

foreach (int fib in EvenNumbersOnly(Fibs(6)))
	Console.WriteLine (fib);

static IEnumerable<int> Fibs (int fibCount) {
	for (int i = 0, prevFib = 1, curFib = 1; i < fibCount; i++)	{
		yield return prevFib;
		int newFib = prevFib+curFib;
		prevFib = curFib;
		curFib = newFib;
	}
}

static IEnumerable<int> EvenNumbersOnly (IEnumerable<int> sequence) {
	foreach (int x in sequence)
		if ((x % 2) == 0)
			yield return x;
}

每一个元素都只有到了最后关头,即执行 MoveNext() ​操作时才会进行计算,下图显示了随时间变化的数据请求和数据输出:

C7.0 核心技术指南 第7版.pdf - p211 - C7.0 核心技术指南 第 7 版-P211-20240128230927

4.7 可空类型

4.7.1 Nullable<T>​ 结构体

Nullable<T>​ 实例的 HasValue​ 为 false​ 时,尝试获得 Value​ 会抛出 InvalidOperationException ​。

对于值类型,我们可以使用 GetValueOrDefault() ​ 方法获得可空类型的值,当 HasValue​ 为 false​ 时,将获得 defalut(T) ​ 对应的值。

MyStruct? value = null;
var result = value.GetValueOrDefault();  // result.Number 为 0
Console.WriteLine(result.Equals(default(MyStruct)));    // true

MyStruct value2 = new MyStruct();   // value2.Number 为 2
Console.WriteLine(value2.Equals(default(MyStruct)));    // false

struct MyStruct{
    public int Number { get; set;} = 1;
  
    public MyStruct(){
        Number = 2;
    }
}

4.7.2 隐式和显式的可空对象转换

  • T​ 到 T?​ 的转换是
  • T?​ 到 T​ 的转换是
int? x = 5;        // implicit
int y = (int)x;    // explicit

显式强制转换与调用可空对象的 Value ​属性实质上是等价的。因此,当 HasValue ​为 false ​的时候将抛出 InvalidOperationException ​异常。

4.7.3 装箱拆箱可空值

如果 T? ​是装箱的,那么堆中的装箱值包含的是 T 。这种优化方式是可行的,因为装箱值已经是一个可以赋值为 null 的引用类型了。

我们可以使用 as 运算符可空类型拆箱,如果转换出错,那么结果为 null

object o = "string";
int? x = o as int?;
Console.WriteLine (x.HasValue);   // False

4.7.4 运算符优先级提升(Operator lifting)

可空类型可以运用比较运算符(<​、>​、== ​等),编译器会从对应值类型借用或者“提升”相应运算符,以下两句代码等价:

int? x = 5;
int? y = 10;

bool b = x < y;      // true
bool b = (x.HasValue && y.HasValue) ? (x.Value < y.Value) : false;

编译器会根据运算符的分类来执行空值逻辑。

4.7.4.1 相等运算符(== ​和 !=​)

提升后的相等运算符可以像引用类型那样处理空值。这意味着两个 null 值是 相等的 ,如下代码分别输出: TrueTrue

Console.WriteLine (         null ==        null);
Console.WriteLine ((double?)null == (long?)null);

而且:

  • 如果 只有一个操作数为 null 那么两个操作数不相等;
  • 如果 两个操作数都不为 null 则比较它们的 Value。

4.7.4.2 关系运算符(>​、< ​等)

null 和任何值比较的结果都是 false ​,关系运算符对 null 的比较是无意义的。当 x​ 或 y​ 为 null,如下代码结果始终为 false ​:

int? x = 5;
int? y = 10;

bool b = x < y;      // true
bool b = (x.HasValue && y.HasValue) ? (x.Value < y.Value) : false;

4.7.4.3 其他运算符(+​、-​、*​、/​、%​、&​、|​、^​、<<​、>>​、+​、++​、--​、! ​和 ~​)

当任意一个操作数为 null 时,这些运算符都会返回 null。这被称为“空合并运算”:

int? c = x + y;
int? c = (x.HasValue && y.HasValue)
		 ? (int?) (x.Value + y.Value) 
		 : null;

4.7.4.4 混合使用可空和非空类型的操作数

T​ → T?​ 存在 隐式转换 机制,因此混用非空和可空是可行的:

int? a = null;
int b = 2;
int? c = a + b;   // c is null - equivalent to a + (int?)b

4.7.5 在 bool? ​上使用 & ​和 | ​运算符

如果操作数的类型为 bool?​​,那么 &​ ​和 |​ ​运算符会将 null 作为一个未知值(Unknown value)看待。则 null | true​ ​应当返回 true ​​,因为:

  • 如果未知值为假的,那么结果为
  • 如果未知值是真的,那么结果为

类似的,null & false​ 的结果为 false ​。以下的例子说明了一些其他组合的用法:

假设有:

bool? n = null;
bool? f = false;
bool? t = true;

则有:

表达式 结果 Console.WriteLine 输出
n | n n null
n | f n null
n | t true True
n & n n null
n & f false False
n & t n null

Error

上述操作仅能用于 |​ 和 &​,||​ 和 &&​ 无法通过编译。

4.7.7 可空类型的应用场景

可空类型常用的场景有二:

  1. 表示 未知的值 ,常用于数据库编程。
  2. 后备字段 ,即所谓的 环境属性(ambient property) 。如果环境属性的值为 null,则 返回父类的值

例如:

public class Row {
    /*...*/
    Grid parent;
    Color? color;

    public Color Color {
	    get { return color ?? parent.Color; }
	    set { color = Color == parent.Color ? (Color?)null : value; }
    }
}

4.8 扩展方法

4.8.2 二义性与解析

4.8.2.2 扩展方法与实例方法

当扩展方法和实例方法同名,将优先调用 实例 方法:

static void Main() {
	new Test().Foo ("string");	// Instance method wins, as you'd expect
	new Test().Foo (123);		// Instance method still wins
}

public class Test {
	public void Foo (object x) { "Instance".Dump(); }    // This method always wins
}

public static class StringHelper {
	public static void Foo (this UserQuery.Test t, int x) { "Extension".Dump(); }
}

此时只能通过 普通的静态调用语法来 使用扩展方法。

4.8.2.3 同名的扩展方法

如果两个扩展方法签名相同,则必须通过普通的静态调用语法来使用。

如果其中一个扩展方法具有更具体的参数,则优先调用 方法。例如:

"Perth".IsCapitalized().Dump();
char[] value = "Perth".ToArray();
value.IsCapitalized().Dump();

static class StringHelper {
	public static bool IsCapitalized (this string s) {
		"StringHelper.IsCapitalized".Dump();
		return char.IsUpper (s[0]);
	}
}

static class EnumerableHelper {
	public static bool IsCapitalized (this IEnumerable<char> s) {
		"Enumerable.IsCapitalized".Dump();
		return char.IsUpper (s.First());
	}
}

上述代码,将先后输出“ StringHelper.IsCapitalized ”、“ Enumerable.IsCapitalized ”。

注意:类型结构体都比接口更加 具体

4.9 匿名类型

匿名类型是一个由编译器临时创建来存储一组值的简单类。如果需要创建一个匿名类型,则可以使用 new​ ​关键字,后面加上** 对象初始化 器**,指定该类型包含的 属性 。例如:

var dude = new { Name = "Bob", Age = 23 };

匿名类型只能通过 var ​ 关键字来引用,因为它并没有一个名字。

匿名类型的属性名推断

匿名类型的属性名称可以从 一个本身为标识符(或者以标识符结尾)表达式 推断得到,如下两段代码等价:

int Age 
var dude = new { Name = "Bob", Age, Age.ToString().Length };
int Age = 23;
var dude = new { Name = "Bob", Age = Age, Length = Age.ToString().Length };
匿名类型的 Type

在同一个程序集内声明的两个匿名类型实例,如果它们的元素 名称类型 是相同的,那么它们在内部就是相同的类型:

var a1 = new { X = 2, Y = 4 };
var a2 = new { X = 2, Y = 4 };
Console.WriteLine (a1.GetType() == a2.GetType());   // True
匿名类型的 Equals 方法

匿名类型重写了 Equals​ 方法从而能够执行比较运算:

Console.WriteLine (a1 == a2);
Console.WriteLine (a1.Equals (a2));

上述代码执行结果为:“ false, true

匿名类型和数组、方法返回值

匿名类型可以用于定义数组:

var dudes = new[]{
    new { Name = "Bob", Age = 30},
    new { Name = "Tom", Age = 40},
};

var ​​ 不能作为方法的返回值类型,因此匿名类型不可用于方法返回值:

var Foo() => new { Name = "Bob", Age = 30 };    // 编译时出错

此时只能用 object ​或 dynamic ​作为返回值。这种方式会丧失静态类型的安全性:

object Foo() => new { Name = "Bob", Age = 30 };
// or
dynamic Foo() => new { Name = "Bob", Age = 30 };

4.10 元组(C#7)

和匿名类型一样,元组(tuple)也是存储一组值的便捷方式。元组的主要目的是代替 out参数 ,从方法中返回多个值,这是匿名类型做不到的。

元组有如下特点:

元组是值类型

元组是 类型,并且元素 可变(可读可写)的:

var joe = bob;                 // joe is a *copy* of job
joe.Item1 = "Joe";             // Change joe’s Item1 from Bob to Joe
Console.WriteLine (bob);       // (Bob, 23)
Console.WriteLine (joe);       // (Joe, 23)
元组和方法返回值

元组可以将 元素类型 列在括号中,来显式指定元组的类型。

(string, int) bob  = ("Bob", 23);    // 也可以使用var

因此元组可以作为方法返回值:

static (string, int) GetPerson() => ("Bob", 23);

(string, int) person = GetPerson();  // 此处可以使用var

4.10.1 元组元素命名

当创建元组字面量时,可以为元组的元素 起一些有意义的名字

var tuple = (Name:"Bob", Age:23);

Console.WriteLine (tuple.Name);     // Bob
Console.WriteLine (tuple.Age);      // 23

也可以在 指定元组类型 时进行命名:

static (string Name, int Age) GetPerson() => ("Bob", 23);

var person = GetPerson();
Console.WriteLine (person.Name);    // Bob
Console.WriteLine (person.Age);     // 23

注意:如果两个元组对应的元素类型相同(按顺序),则二者 是兼容的 。而其元素命名 可以 不同:

(string Name, int Age, char Sex)  bob1 = ("Bob", 23, 'M');
(string Age,  int Sex, char Name) bob2 = bob1;   // No error!

这将造成令人困惑的结果:

Console.WriteLine (bob2.Name);    // M
Console.WriteLine (bob2.Age);     // Bob
Console.WriteLine (bob2.Sex);     // 23

类型擦除

元组实际上 ValueTuple<> ​ 类型,类似于 (string, int)​ 的元组是 ValueTuple<string, int> ​ 的别名。编译时,将会抹去自定义的元素名称,直接使用“ Item1 ​、 Item2 ​、……”。因此,在绝大多数情况下,都不能用 反射(reflection) 确定元组在运行时的命名。

C7.0 核心技术指南 第7版.pdf - p224 - C7.0 核心技术指南 第 7 版-P224-20240201133146

4.10.2 ValueTuple.Create ​​

我们还可以通过 ValueTuple​ ​的 泛型工厂方法 创建元组:

ValueTuple<string,int> bob1 = ValueTuple.Create ("Bob", 23);
(string, int)          bob2 = ValueTuple.Create ("Bob", 23);

这种方式无法创建命名元素。

4.10.3 元组的解构(Deconstruct)

元组隐式支持解构模式,其书写方式和定义元组变量十分相似,如下代码指出了它们的区别:

var bob = ("Bob", 23);

(string name, int age)      = bob;
(string name, int age) bob2 = bob;

其中第 3 行 为解构器 ,第 4 行 新定义了一个元组

4.10.4 元组的比较

和匿名类型一样,ValueTuple<>​ ​类型重写了 Equals​ ​方法,可以进行 元组间的比较 ,也可以作为 字典中的 Key

var t1 = ("one", 1);
var t2 = ("one", 1);
Console.WriteLine (t1.Equals (t2));    // True
Console.WriteLine (t1 == t2);          // True

ValueTuple<>​ ​也实现了 IComparable ​ ​接口,因此元组可以作为排序的 Key。

4.10.5 System.Tuple ​类

System.Tuple ​于.NET Framework 4.0 引入,为引用类型(class),相比之下 ValueTuple ​无优势,不推荐使用。因此微软在 C#7 中增加了对 ValueTuple ​的支持,也提供了较多的语法糖。

4.11 Attribute

4.11.2 Attribute​ 的 命名 参数和 位置 参数

[XmlElement("Customer", Namespace = "http://oreilly.com")]
public class CustomerEntity { ... }

特性参数分为“ 位置 参数”和“ 命名 参数”,上述代码中 Customer​ ​为“ 位置 参数”,Namespace​ ​为“ 命名 参数”。

4.11.3 特性的目标

特性不仅可以应用在类型或成员上,还可以应用于 程序集

[assembly : CLSCompliant(true)]

上述代码将 CLSCompliant ​应用至整个程序集。

4.12 调用者信息 Attribute(C#5)

可选参数可以添加如下 Attribute​,让编译器从调用者源代码获取参数的默认值:

  • CallerMemberName

    调用者的 成员名称

  • CallerFilePath

    调用者的 源代码文件路径

  • CallerLineNumber

    调用者的 源代码文件行号

假设我们的程序位于 c:\source\test\Program.cs

static void Main() => Foo();

static void Foo (
	[CallerMemberName] string memberName = null,
	[CallerFilePath] string filePath = null,
	[CallerLineNumber] int lineNumber = 0)
{
	Console.WriteLine (memberName);
	Console.WriteLine (filePath);
	Console.WriteLine (lineNumber);
}

这段代码等价于:

static void Main() => Foo("Main", @"c:\source\test\Program.cs", 6);

输出结果:

Main
c:\source\test\Program.cs
6

调用者信息 Attribute 的应用

我们可以配合 INotifyPropertyChanged ​接口(位于 System.ComponentModel​)使用,实现记录参数变化信息:

public class Foo : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
  
    void RaisePropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
    string customerName;
    public string CustomerName 
    {
        get => customerName;
        set 
        {
            if(value == customerName)
                return;
            customerName = value;
            RaisePropertyChanged();
        }
    }
}

4.13 动态绑定

4.13.1 静态绑定与动态绑定

  • 静态绑定

    在编译阶段绑定。

  • 动态绑定

    通过 dynamic ​ 类型实现,编译时不知道具体类型,仅对表达式 进行打包 ,绑定在 运行时 执行。

动态绑定有两种方式:

  1. 自定义绑定

通过动态对象的 IDynamicMetaObjectProvider ​ ​接口执行绑定。
2. 语言绑定

动态类型未实现上述接口时,编译器将执行该绑定。

4.13.4 RuntimeBinderException

如果成员绑定失败,那么程序会抛出该异常,可以将其看做一个运行时编译时错误:

dynamic d = 5;
d.Hello();         // throws RuntimeBinderException

4.13.5 动态类型的运行时表示

dynamic​​​ 和 object ​​​ 类型深度等价,如下表达式的结果均为 true ​​​:

(typeof (dynamic) == typeof (object)).Dump();    // 实际上这句话无法通过编译

(typeof (List<dynamic>) == typeof (List<object>)).Dump();
(typeof (dynamic[]) == typeof (object[])).Dump();

动态引用可以指向除 指针 类型外的任何类型对象:

dynamic x = new object();
Console.WriteLine (x.GetType().Name);  // String

x = 123;  // No error (despite same variable)
Console.WriteLine (x.GetType().Name);  // Int32

object ​ 对象可以转换为 dynamic​,以便执行任意的动态操作:

object o = new System.Text.StringBuilder();
dynamic d = o;
d.Append ("hello");
Console.WriteLine (o);   // hello

C7.0 核心技术指南 第7版.pdf - p233 - C7.0 核心技术指南 第 7 版-P233-20240203131942

4.13.6 动态转换

动态类型可以 隐式 从其他类型转换或转换为其他类型。

如下代码 int ​可以 隐式 转换为 long​,因此转换成功:

int i = 7;
dynamic d = i;
long j = d;

如下代码则会抛出 RuntimeBinderException ​ ​异常:

int i = 7;
dynamic d = i;
short s = d;

Eureka

从这里可以看出,把 int​ 值赋给 dynamic​ 并未装箱,把 dynamic​ 值赋给 long​ 也未拆箱,否则会抛出异常。

而第二段代码运行失败,是无法将 int​ 数据隐式转为 short​ 引发的异常。改成如下方式则可以正常执行:

short s = (short)d;

4.13.9 不可调用的函数

有些函数无法通过动态调用,例如:

  1. 扩展方法(通过扩展方法语法)
  2. 显式实现的接口成员
  3. 被隐藏的基类成员

这些方法 DLR 无法进行绑定

4.14 运算符重载

可以进行重载的运算符如下:

一元运算符 + - ! ~ ++ --
4 则运算 + - * / %
位运算 & \| ^ << >>
比较 == != > < >= <=

此外还有:

  • 隐式转换(implicit​)和显式转换(explicit​)。
  • true ​和 false ​运算符。

可以间接重载的运算符有:

  • 复合赋值运算符(+=​、-= ​等)

    可以通过重写非复合运算符(+、-等)隐式重写。

  • 条件运算符(&& ​和 ||​)

    可以通过重写按位操作运算符 & ​和 | ​隐式重写。

4.14.1 运算符函数

运算符函数具有一些规则:

  • 函数名: operator ​ 关键字 + 运算符符号。
  • 运算符函数必须是 static ​ 和 public ​ 的。
  • 运算符函数的 参数 即操作数。
  • 运算符函数的 返回类型 表示表达式的结果。
  • 运算符函数的操作数中 至少有一个类型 和声明运算符函数的类型是一致的。

重载运算符会自动支持相应的复合赋值运算符,如下代码因重载了 + ​号,因此可以使用 += ​:

public struct Note {
    int value;
	public static Note operator + (Note x, int semitones) {
        return new Note (x.value + semitones);
    }
}

和方法与属性一样,只含有一个表达式的运算符可以改为表达式体:

public static Note operator + (Note x, int semitones)
                               => new Note (x.value + semitones);

4.14.3 自定义隐式和显式转换

对于弱相关类型,相较重载转换,使用如下方法更为合适:

  • 以转换类型为参数,定义构造器。
  • 通过工厂方法(如 ToXXX FromXXX)转换。

另外,有:

  • 隐式转换: 一定 成功,转换时 会丢失信息。
  • 显式转换: 不一定 成功,转换时 可能 会丢失信息。

C7.0 核心技术指南 第7版.pdf - p240 - C7.0 核心技术指南 第 7 版-P240-20240203151401

4.14.4 重载 true​ 和 false

true​ 和 false​ 运算符只会在那些本身有布尔语义但无法转换为 bool​ 的类型中重载(这种类型并不多见)。通过重载 true​ 和 false​ 运算符,类型可以直接使用如下语句:if​、do​、while​、for​、&&​、||​ 和 ?:​。

public struct SqlBoolean
{
	public static bool operator true (SqlBoolean x) => x.m_value == True.m_value;

	public static bool operator false (SqlBoolean x) => x.m_value == False.m_value;

	public static SqlBoolean operator ! (SqlBoolean x)
	{
		if (x.m_value == Null.m_value)  return Null;
		if (x.m_value == False.m_value) return True;
		return False;
	}

	public static readonly SqlBoolean Null =  new SqlBoolean(0);
	public static readonly SqlBoolean False = new SqlBoolean(1);
	public static readonly SqlBoolean True =  new SqlBoolean(2);

	SqlBoolean (byte value) { m_value = value; }
	byte m_value;
}


static void Main()
{
	SqlBoolean a = SqlBoolean.Null;
	if (a)
		Console.WriteLine ("True");
	else if (!a)
		Console.WriteLine ("False");
	else
		Console.WriteLine ("Null");
}

4.15 不安全的代码和指针

4.15.2 不安全的代码

使用 unsafe​ ​关键字修饰 类型类型成员语句块 ,就可以在该范围内使用 指针 类型,并可以像 C++ 那样对作用域内的内存执行指针操作:

unsafe static void BlueFilter (int[,] bitmap)
{
    int length = bitmap.Length;
    fixed (int* b = bitmap)
    {
        int* p = b;
        for (int i = 0; i < length; i++)
            *p++ &= 0xFF;
    }
}

不安全代码比对应的安全代码运行的速度更快。由于没有穿越托管运行环境的开销,不安全的 C# 方法可能比调用外部 C 函数的执行速度更快。

4.15.3 fixed 语句

fixed 语句用于锁定托管对象。GC 会回收不必要的内存、整理碎片化内存,这会干扰指针的使用,此时可以使用 fixed 语句锁定该对象,GC 将不会移动它:

Test test = new Test();
unsafe
{
   fixed (int* p = &test.X)   // Pins test
   {
        *p = 9;
   }
   Console.WriteLine (test.X);
}

注意:fixed 语句对运行时效率可能会产生一定影响,因此 fixed 代码块应当只供短暂使用,而且在代码块中应当避免堆上的内存分配。

Info

更多内容,见fixed​ 和 fixed{...}​

4.15.4 指针取成员运算符

除了 &​ 和 *​ 运算符,C#​ 还支持 C++​ 形式的 -> ​ 运算符。该运算符可以在 结构体 上使用:

struct Test
{
    public int X;
}
unsafe static void Main()
{
    Test test = new Test();
    Test* p = &test;
    p->X = 9;
    Console.WriteLine (test.X);
}

4.15.5 数组

4.15.5.1 stackalloc​ 关键字

stackalloc ​关键字将在 上显式分配一块内存。由于内存是在 上分配的,因此其生命周期和 其他局部变量((这里的局变量指那些没有被Lambda表达式、迭代块,或异步方法捕获而使生命周延长的变量)) 一致,也受限于方法执行期。

可以在这块内存上使用 [] ​ ​运算符对其进行索引访问:

unsafe
{
    int* a = stackalloc int [10];
    for (int i = 0; i < 10; ++i)
        Console.WriteLine (a[i]);   // Print raw memory
}

4.15.5.2 固定大小的缓冲区

fixed 关键字的另外一个用途是在结构体中创建 固定大小的缓冲区

unsafe struct UnsafeUnicodeString
{
	public short Length;
	public fixed byte Buffer[30];
}

上述 UnsafeUnicodeString ​实例的 Buffer 直接有 30byte 空间可以使用:

unsafe class UnsafeClass
{
    UnsafeUnicodeString uus;
    public UnsafeClass (string s)
    {
        uus.Length = (short)s.Length;
        fixed (byte* p = uus.Buffer)
        {
            for (int i = 0; i < s.Length; i++)
                p[i] = (byte)s[i];
        }
    }
}

4.15.6 void*

void 指针(void*​)被用作一种通用的指针类型,可以指向 任何 类型的数据。由于 void*​ 不预设任何数据类型,因此它非常适合于那些需要直接与内存打交道但又不需要关心具体数据类型的场景。

任意的指针都可以 式转换为 void*​。void* 可以解引用,且算术运算符 也不能 在 void 指针上使用,例如:

unsafe static void Main()
{
	short[] a = {1,1,2,3,5,8,13,21,34,55};
	fixed (short* p = a)
	{
		//sizeof returns size of value-type in bytes
		Zap (p, a.Length * sizeof (short));
	}
	foreach (short x in a)
		System.Console.WriteLine (x);   // Prints all zeros
}

unsafe static void Zap (void* memory, int byteCount)
{
	byte* b = (byte*) memory;
		for (int i = 0; i < byteCount; i++)
			*b++ = 0;
}

4.16 预处理指令

预处理指令向编译器提供关于一段代码的附加信息。预处理符号可以定义在 源代码 中,也可以通过命令行参数 /define:symbol ​ ​传递给编译器。

#if #elif 指令

这两个指令可以使用 || ​​、 && ​ ​和 ! ​ ​运算符对多个符合进行逻辑运算。

#error #warning符号

这些符号可以避免条件指令的滥用。它可以在出现不符合要求的编译符号时产生一条错误或警告信息。

下表列出了预处理指令:

预处理指令 操作
#define symbol 定义 symbol 符号
#undef symbol 取消 symbol 符号的定义
#if symbol [operator symbol2]... 判断 symbol 符号
#else 执行 #if​、#elif​ ​剩余内容
#elif symbol [operator symbol2] 组合 #else​ ​和 #if
#endif 结束条件指令
#warning text 在编译器输出中显示 text 警告信息
#error text 在编译器输出中显示 text 错误信息
#pragma warning [disable \| restore] 禁用/恢复 编译器警告
#line [number["file"] \| hidden] number 是 源代码的行号 ;file 是 输出的文件名 ;hidden 指示调试器 忽略此处到下一个 #line指令之间的代码
#region name 标记大纲的开始位置
#endregion 结束一个大纲区域

4.16.1 ConditionalAttribute

Conditional​ 修饰的 特性 ,只有在给定的 预处理符号 出现时才编译。例如:

using System;
using System.Diagnostics;
[Conditional("DEBUG")]
public class TestAttribute : Attribute { }
#define DEBUG
[Test]
class Foo {
	[Test]
	string s;
}

上述代码,仅当第二个代码块定义了 DEBUG 时,编译器才会将 [Test] ​ 特性加入进来。

Notice

即使 TestAttribute​ 定义在另一个程序集中,也可以这么用。

Info

实际上,ConditionalAttribute​ 还可以应用于方法。

4.16.2 Pragma 警告

编译器会自动提示我们一些代码缺陷,不过这些警告可能是“虚假警告”,此时我们可以通过 #pragma warning ​配合 disable ​、 restore ​指令禁用/启用它们:

public class Foo {
    static void Main() { }
    #pragma warning disable 414
    static string Message = "Hello";
    #pragma warning restore 414
}

如果你希望彻底应用该指令,可以用 /warnaserror ​​ 开关让编译器将所有警告都显示为错误

4.17 XML 文档

文档注释有两种方式:

/// <summary>
/// Cancels a running query.
/// </summary>
public void Cancel() { }
/**
 * <summary>
 * Cancels a running query.
 * </summary>
 * */
public void Cancel() { }

4.17.1 标准的 XML 文档标签

  • <summary>

    摘要,一般为一个短语或句子。

  • <remarks>

    备注,附加描述信息。

  • <param>

    <param name="name">...</param>
    

    对方法参数的解释。

  • <returns>​​​

    对方法返回值的解释。

  • <exception>​​​

    <exception cref=["type"]>...</exception>
    

    列出改方法可能抛出的一种异常。

  • <permission>​​​

    说明类型或成员所需的 IPermission​​ 类型。

  • <example>​​​

    代表文档生成器使用的示例。源代码一般位于 <c>​ ​或 <code>​ ​标签内。

  • <c>​​

    行内代码。

  • <code>

    多行代码的示例。该标签通常在<example>​标签内。

  • <see>

    交叉引用另一个类型或成员中。HTML文档生成器通常将其转换为超链接。

    如果引用无效,编译器会产生一条警告信息;如果要引用泛型类型,需要使用花括号:

    cref="Foo{T, U}"
    
  • <seealso>

    交叉引用另一个类型或成员。文档生成器通常将其写入页面下方一个独立的“See Also”小节中。

  • <paramref>

    在其他标签内引用参数。

  • <list>

    <list type="[bullet | number | table]">
        <listheader>
            <term>...</term>
            <description>...</description>
        </listheader>
        <item>
            <term>...</term>
            <description>...</description>
        </item>
    </list>
    

    令文档生成器生成一个带有项目符号、列表或表格式的列表

  • <para>

    令文档生成器将指定内容单独作为一个段落。

  • <include>

    <include file='filename' path='tagpath[@name=""]'>...</include>
    

    合并一个包含文档的外部XML文件。path属性的值用于查询该文件中某个特定元素的XPath查询。

posted @   hihaojie  阅读(18)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示