[搬运] C# 这些年来受欢迎的特性

原文地址:http://www.dotnetcurry.com/csharp/1411/csharp-favorite-features

在写这篇文章的时候,C# 已经有了 17 年的历史了,可以肯定地说它并没有去任何地方。C# 语言团队不断致力于开发新特性,改善开发人员的体验。

在这篇文章中,我在介绍 C# 历史版本的同时分享我最喜欢的特性,在强调实用性的同时展示其优点。
csharp-versions.jpg

C# 1.0

C#1.0 (ISO-1) 确实算是语言,却没有什么令人兴奋的,缺少许多开发人员喜欢的特性。仔细一想,我能说得出喜欢的只有一个特别的特性 - 隐式和显式接口实现

接口在现今开发 C# 的过程中仍然流行使用,以下面的 IDateProvider 接口为例。

public interface IDateProvider
{
    DateTime GetDate();
}

没有什么特别的,现在着手两种实现方式 - 其中第一种是隐式实现,如下:

public class DefaultDateProvider : IDateProvider
{
    public DateTime GetDate()
    {
        return DateTime.Now;
    }
}

第二种实现是如下的显式实现方式:

public class MinDateProvider : IDateProvider
{
    DateTime IDateProvider.GetDate()
    {
        return DateTime.MinValue;
    }
}

注意显式实现如何省略访问修饰符。此外,方法名称被写为 IDateProvider.GetDate() ,它将接口名称作为限定符的前缀。
这两件事情使得调用更明确的。

显式接口实现的一个很好的方面是它强制消费者依赖于接口。显式实现接口的实例对象必须使用接口本身,而没有其他可用的接口成员!

hidden-interface-members.png

但是,当您将其声明为接口或将此实现作为期望接口的参数传递时,成员将如预期可用。

interface-members.png

这是特别有用的方面,因为它强制使用接口。通过直接使用接口,不会将代码耦合到底层实现。同样,明确的接口实现避免命名或方法签名歧义 - 并使单个类可以实现具有相同成员的多个接口。

Jeffery Richter 在他 CLR via C# 一书中提醒了我们显式的接口实现两个主要问题是值类型实例在投射到一个接口和明确实现的方法时将被装箱,同时不能被派生类调用。

请记住,装箱和拆箱会影响性能。任何编程中,你应该评估用例来确保善用工具。

C# 2.0

作为参考,我将列出C# 2.0 (ISO-2) 的所有特性。

  • 匿名方法
  • 协变和逆变
  • 泛型
  • 迭代器
  • 可空类型
  • 部分类型

我最在最喜欢 泛型 还是 迭代器 之间的摇摆,对我来说这是一个非常困难的选择,最终还是更喜欢泛型,顺便说说其中缘由。

因为相比于写迭代器,我更频繁地使用泛型。在 C# 中很多 SOLID 编程原则都是使用泛型来强化的,同样它也有助于保持代码的 干爽。不要误解我的意思,我同时也写了一些迭代器,在 C# 同样中值得采用!

让我们更详细地看看泛型。

编者注:学习如何 在 C# 中 使用泛型来提高应用程序的可维护性

泛型向.NET Framework引入了类型参数的概念,这使得可以设计类和方法来推迟一个或多个类型的规范,直到类或方法被客户端代码声明和实例化为止。

让我们想象一下,我们有一个名为 DataBag 的类,作为一个数据包。它可能看起来像这样:

public class DataBag
{
    public void Add(object data)
    {
        // omitted for brevity...
    }            
}

起初看起来这似乎是一个很棒的想法,因为你可以在这个 DataBag 的实例中添加任何东西。但是当你真正想到这意味着什么的时候,会觉得相当骇人。

所有添加的内容都隐式地包装为 System.Object 。此外,如果添加了值类型,则会发生装箱。这些是您应该注意的性能考虑事项。

泛型解决了这一切,同时也增加了类型安全性。让我们修改前面的例子,在类中包含一个类型参数 T ,并注意方法签名的变化。

public class DataBag
{
    public void Add(T data)
    {
        // omitted for brevity...
    }
}

例如现在一个 DataBag 实例将只允许调用者添加 DateTime 实例。要类型安全,没有装箱或拆箱 ... 让更美好的事情发生。

泛型类型参数也可以被约束。通用约束是强有力的,因为它们必须遵守相应的约束条件,只允许有限范围的可用类型参数。

有几种编写泛型类型参数约束的方法,请考虑以下语法:

public class DataBag where T : struct { /* T 值类型 */ }
public class DataBag where T : class { /* T 类、接口、委托、数组 */ }
public class DataBag where T : new() { /* T 有无参构造函数 */ }
public class DataBag where T : IPerson { /* T 继承 IPerson */ }
public class DataBag where T : BaseClass { /* T 派生自 BaseClass */ }
public class DataBag where T : U { /* T 继承 U, U 也是一个泛型参数 */ }

多个约束是允许的,用逗号分隔。类型参数约束立即生效,即编译错误阻止程序员犯错。考虑下面的DataBag约束。

public class DataBag where T : class
{
    public void Add(T value)
    {
        // omitted for brevity...
    }
}

现在,如果我试图实例化DataBag,C#编译器会让我知道我做错了什么。更具体地说,它要求类型 'DateTime' 必须是一个引用类型,以便将其作为 'T' 参数用于泛型类型或 'Program.DataBag' 方法中。

C# 3.0

下面是C#3.0的主要特性列表。

  • 匿名类型
  • 自动实现的属性
  • 表达树
  • 扩展方法
  • Lambda表达
  • 查询表达式

我徘徊于选择 Lambda表达式 还是 扩展方法 。但是,联系我目前的 C# 编程,相对于任何其他的 C# 运算符,我更多地使用lambda 操作符。我无法表达对它的喜爱。
在C#中有很多机会来利用 lambda 表达式和 lambda 运算符。=> lambda 运算符用于将左侧的输入与右侧的 lambda 表达式体隔离开来。

一些开发人员喜欢将 lambda 表达式看作是表达委托调用的一种较为冗长的方式。Action、Func 类型只是 System 名称空间中的预定义的一般委托。

让我们从解决一个假设的问题开始,使用 lambda 表达式来帮助我们编写一些富有表现力和简洁的 C# 代码。

想象一下,我们有大量代表趋势天气信息的记录。我们可能希望对这些数据执行一些操作,不是在一个典型的循环中遍历它,而是在某个时候,我们可以采用不同的方式。

public class WeatherData
{
    public DateTime TimeStampUtc { get; set; }
    public decimal Temperature { get; set; }
}
 
private IEnumerable GetWeatherByZipCode(string zipCode) { /* ... */ }

由于 GetWeatherByZipCode 的调用返回一个 IEnumerable,它可能看起来想让你循环迭代。假设我们有一个方法来计算平均温度。

private static decimal CalculateAverageTemperature(
    IEnumerable weather, 
    DateTime startUtc, 
    DateTime endUtc)
{
    var sumTemp = 0m;
    var total = 0;
    foreach (var weatherData in weather)
    {
        if (weatherData.TimeStampUtc > startUtc &&
            weatherData.TimeStampUtc < endUtc)
        {
            ++ total;
            sumTemp += weatherData.Temperature;
        }
    }
    return sumTemp / total;
}

我们声明一些局部变量来存储所有过滤日期范围内的温度总和及其总和,以便稍后计算平均值。在迭代内是一个 if 逻辑块,用于检查天气数据是否在特定的日期范围内。可以重写如下:

private static decimal CalculateAverageTempatureLambda(
    IEnumerable weather,
    DateTime startUtc,
    DateTime endUtc)
{
    return weather.Where(w => w.TimeStampUtc > startUtc &&
                              w.TimeStampUtc  w.Temperature)
                  .Average();
}

正如你所看到的那样,极大地简化了代码。if 逻辑块实际上只是一个谓词,如果天气日期在范围内,我们将继续进行一些额外的处理 - 就像一个过滤器。然后就像调用 Average 一样,当我们需要合计温度时,我们只需要投射 (或选择) IEnumerable 的温度过滤列表。

在 IEnumerable 接口上的 Where 和 Select 扩展方法中,使用 lambd a 表达式作为参数。Where 方法需要一个 Func<T, bool> ,Select 方法 需要一个 Func 。

C# 4.0

相比之前的版本,C# 4.0 新增的主要特性较少。

  • 动态绑定
  • 嵌入式互操作类型
  • 泛型中的协变和逆变
  • 命名/可选参数

所有这些特性都是非常有用的。但是对于我来说,更倾向于命名可选参数,而不是泛型中的协变和逆变。这两者的取舍,取决于哪个是我最常用的,以及近年来最令 C# 开发人员受益的那个特性。

命名可选参数实至名归,尽管这是一个非常简单的特性,其实用性却很高。我就想问,谁没有写过重载或者带有可选参数的方法?

当您编写可选参数时,您必须为其提供一个默认值。如果你的参数是一个值类型,那么它必须是一个文字或者常数值,或者你可以使用 default 关键字。同样,您可以将值类型声明为 Nullable ,并将其赋值为 null 。假设我们有一个带有 GetData 方法的仓储。

public class Repository
{
    public DataTable GetData(
        string storedProcedure,
        DateTime start = default(DateTime),
        DateTime? end = null,
        int? rows = 50,
        int? offSet = null)
    {
        // omitted for brevity...        
    }
}

正如我们所看到的,这个方法的参数列表相当长 - 好在有好几个可选参数。因此,调用者可以忽略它们,并使用默认值。正如你声明的那样,我们可以通过只传递 storedProcedure 参数来调用它。

var repo = new Repository();
var sales = repo.GetData("sp_GetHistoricalSales");

现在我们已经熟悉了可选参数特性以及这些特性如何工作,顺便使用一下命名参数。以上面的示例为例,假设我们只希望我们的数据表返回 100 行而不是默认的 50 行。我们可以将我们的调用改为包含一个命名参数,并传递所需的重写值。

var repo = new Repository();
var sales = repo.GetData("sp_GetHistoricalSales", rows: 100);

C# 5.0

像C#4.0版本一样,C#5.0版本中没有太多特性 - 但是其中有一个特性非常强大。

  • 异步/等待
  • 调用方信息

当 C# 5.0 发布时,它实际上改变了 C# 开发人员编写异步代码的方式。今天仍然有很多困惑,我在这里向您保证,这比大多数人想象的要简单得多。这是 C# 的一个重大飞跃 - 它引入了一个语言级别的异步模型,它极大地赋予了开发人员编写外观和感觉同步 (或者至少是连续的) 的“异步”代码。

异步编程在处理 I/O 相关(如与数据库、网络、文件系统等进行交互)时非常强大。异步编程通过使用非阻塞方法帮助处理吞吐量。这种机制在透明的异步状态机中代以使用暂停点和相应的延续的方式。

同样,如果 CPU 负载计算的工作量很大,则可能需要考虑异步执行此项工作。这将有助于用户体验,因为UI线程不会被阻塞,而是可以自由地响应其他UI交互。

编者注:关于 C# 异步编程中使用异步等待的最佳实践,http://www.dotnetcurry.com/csharp/1307/async-await-asynchronous-programming-examples

在 C# 5.0 中,当语言添加了两个新的关键字async和await时,异步编程被简化了。这些关键字适用于 Task 和 Task 类型。下表将作为参考:
async-await.png

Task 和 Task 类型表示异步操作。这些操作既可以通过返回一个 Task ,也可以返回void Task。当您使用 async 关键字修改返回方法时,它将使方法主体能够使用await 关键字。在评估 await 关键字时,控制流将返回给调用者,并在该方法中的那一点暂停执行。当等待的操作完成时,会同时恢复执行。

class IOBoundAsyncExample
{
    // Yes, this is the internet Chuck Norris Database of jokes!
    private const string Url = "http://api.icndb.com/jokes/random?limitTo=[nerdy]";
 
    internal async Task GetJokeAsync()
    {
        using (var client = new HttpClient())
        {
            var response = await client.GetStringAsync(Url);
            var result = JsonConvert.DeserializeObject(response);
 
            return result.Value.Joke;
        }
    }
}
public class Result
{
    [JsonProperty("type")] public string Type { get; set; }
    [JsonProperty("value")] public Value Value { get; set; }
}
 
public class Value
{
    [JsonProperty("id")] public int Id { get; set; }
    [JsonProperty("joke")] public string Joke { get; set; }
}

我们用一个名为 GetJokeAsync 的方法定义一个简单的类,当我们调用方法时,该方法返回一个 Task 。对于调用者,GetJokeAsync 方法最终会给你一个字符串 - 或可能出错。

当响应返回时,从被暂停的地方恢复延续执行。然后,将结果 JSON 反序列化到 Result类的实例中,并返回 Joke 属性。

C# 6.0

C# 6.0 有很多很不错的改进,很难选择我最喜欢的特性。

  • 字典初始化
  • 异常过滤器
  • 表达式体成员
  • nameof 操作符
  • 空合并运算符
  • 属性初始化
  • 静态引用
  • 字符串插值

我把范围缩小到三个突出的特性:字符串插值,空合并运算符和 nameof 操作符。

尽管 nameof 操作符很棒,而且我经常用,但是显然另外两个特性更具影响力。又是一个两难的选择,最终还是字符串插值获胜出。

空合并运算符很有用,它能让我少写代码,但不一定防止我的代码中的错误。而使用字符串插值时,可以防止运行时出错。

使用 $ 符号插入字符串文字时,将启用 C# 中的字符串插值语法。相当于告诉 C# 编译器,我们要用到各种 C# 变量、逻辑或表达式来插入到此字符串。这对于手动拼接字符串、甚至是 string.Format 方法来说是一个重要的升级。先看一看如下代码:

class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
 
    public override string ToString()
        => string.Format("{0} {1}", FirstName);
}

我们有一个简单的 Person 类,具有两个属性,表示名字和姓氏。我们使用 string.Format 重写 ToString 方法。问题是,编译时,开发人员在希望将姓氏也作为结果字符串的一部分时,使用 “{0} {1} ”参数很容易出错。如上述代码中,他们忘了加姓氏。同样,开发人员可以很容易地交换参数位置,在混乱的格式文字只传递了第一个索引,等等...现在考虑用字符串插值实现。

class Person
{
    public string FirstName { get; set; } = "David";
    public string LastName { get; set; } = "Pine";
    public DateTime DateOfBirth { get; set; } = new DateTime(1984, 7, 7);
 
    public override string ToString()
        => $"{FirstName} {LastName} (Born {DateOfBirth:MMMM dd, yyyy})";
}

我冒昧添加 DateOfBirth 属性和一些默认的属性值。另外,我们现在使用字符串插值重写 ToString 方法。作为一名开发人员,犯上述错误要困难得多。最后,我也可以在插值表达式中进行格式化。注意第三次插值,DateOfBirth 是 DateTime 类型 - 因此我们可以使用习惯的所有标准格式。只需使用 :运算符来分隔变量和格式化。

示例输出:

  • David Pine (Born July 7, 1984)

编者注:有关C#6.0新特性的详细内容,请阅读 http://www.dotnetcurry.com/csharp/1042/csharp-6-new-features

C# 7.0

  • 表达式体成员
  • 局部方法
  • Out 变量
  • 模式匹配
  • 局部引用和引用返回
  • 元组和解构

模式匹配、元组和 Out 变量之间,我选择了 Out 变量。
模式匹配是伟大的,但我真的不觉得自己经常使用它,至少现在还没有。也许我会在将来更多地使用它,但是到目前为止我所写的所有 C# 代码中,没有太多的地方可以运用。再次,这是一个了不起的特性,只不过不是我最喜欢的 C# 7.0 特性。

元组也是一个很好的改进,是服务于语言的这一重要部分,能成为一等公民真是值得庆祝。逃离了 .Item1,.Item2,.Item3等...的日子,但这么说不够准确,在反序列化中无法还原元组名称使这个公共 API 不太有用。

我同时不喜欢可变的 ValueTuple 类型。不明白这是谁设计的,希望有人能向我解释,感觉就像是一个疏忽。因此,只有 Out 变量合我心意。

从 C# 版本1.0以来,try-parse 模式已经在各种值类型中出现了。模式如下:

public boolean TryParse(string value, out DateTime date)
{
    // omitted for brevity...
}

该函数返回一个布尔值,指示给定的字符串值是否能够被解析。如果为 true,则将解析后的值分配给 data参数。它使用方式如下:

if (DateTime.TryParse(someDateString, out var date))
{
    // date is now the parsed value
}
else
{
    // date is DateTime.MinValue, the default value
}

这种模式尽管有用的,却有点麻烦。有时开发人员采取相同的模式,无论解析是否成功。有时可以使用默认值。C# 7.0中的 out变量使得这个更加复杂,尽管我不觉得复杂。

if (DateTime.TryParse(someDateString, out var date))
{
    // date is now the parsed value
}
else
{
    // date is DateTime.MinValue, the default value
}

现在我们移除了 if 语句块的外部声明,并把声明作为参数本身的一部分。使用 var 是合法的,因为类型是已知的。最后,date 变量的范围没有改变。它在声明中内联回 if 语句块之前。

你可能会问:“为什么这是我最喜欢的功能之一?”......这种看起来真的没有什么变化。

不要怀疑,它使我们的 C# 代码更具有表现力。每个人都喜欢扩展方法吧,那么请思考以下代码:

public static class StringExtensions
{
    private delegate bool TryParseDelegate(string s, out T result);
 
    private static T To(string value, TryParseDelegate parse)
        => parse(value, out T result) ? result : default;
 
    public static int ToInt32(this string value)
        => To(value, int.TryParse);
 
    public static DateTime ToDateTime(this string value)
        => To(value, DateTime.TryParse);
 
    public static IPAddress ToIPAddress(this string value)
        => To(value, IPAddress.TryParse);
 
    public static TimeSpan ToTimeSpan(this string value)
        => To(value, TimeSpan.TryParse);
}

这个扩展方法类看起来简洁、明确、强有力。在定义了一个遵循 try-parse 模式的私有委托之后,我们可以编写一个泛型复合方法,它可以传递泛型类型参数、字符串和 tryparse 泛型委托。现在我们可以放心地使用这些扩展方法,用法如下:

ublic class Program
{
    public static void Main(string[] args)
    {
        var str =
            string.Join(
                "",
                new[] { "James", "Bond", " +7 " }.Select(s => s.ToInt32()));
 
        Console.WriteLine(str); // prints "007"
    }
}

编辑注意:要了解C#7的所有新功能,请查看教程 http://www.dotnetcurry.com/csharp/1286/csharp-7-new-expected-features

结论

这篇文章对我个人而言颇具挑战性。C# 的许多特性受我喜欢,因此在每个版本选出一个最喜欢的特性是非常困难的。

每个 C# 版本都包含了强大而有影响力的特性。C# 语言团队以无数的方式进行创新 - 其中之一就是迭代发布。在撰写本文时,C#7.1 和 7.2已正式发布。作为 C# 开发人员,我们正在生活在令人激动人心的语言进化时代!

排列出所有特性对我来说是非常有指示,有助于揭示哪些是实际有用的,哪些对我日常影响最大。我会一如既往的努力,成为务实的开发者!并非每一种特性对于手头的工作来说都是必要的,但了解什么是可用的是很有必要的。

当我们期待 C# 8 的提议和原型时,我对 C# 的未来感到兴奋,它正满怀信心、积极地试图减轻 “十亿美元的错误” (译者注: 图灵奖得主 Tony Hoare 曾指出空引用将造成十亿美元损失)

引用

posted @ 2018-01-21 17:07  张蘅水  阅读(...)  评论(... 编辑 收藏