前言

为了提升一下自己的姿势水平,买了 Effective C#More Effective C# 两本书,想一边阅读一边强迫自己做点笔记(主要是自己的理解),不然怕是看了就忘。另外手头还有一本《深入解析C# ( C# in Depth )》其实我已经阅读了一遍,但感觉没咋读懂,下次有机会也用这种方式重读一遍。今天这篇就先从 Effective C# 的第一章开始,后续慢慢更新。

第一章 C#语言的编程习惯

第1条 优先使用隐式类型的局部变量

简单来说就是在声明局部变量时,应该使用var而尽量避免使用类型的全称。var的引入主要是为了匿名类型和Linq,vardynamic动态类型不同,它是靠编译器自动推断出变量的实际类型。

这个还是比较好理解的,因为用var很多时候方便且不会影响对代码的理解,常用的几个场景比如

// 很容易就能看出该变量是什么类型,用var简洁
string s1 = "66ccff";   
var s2 = "66ccff";

Dictionary<int, List<Tuple<int, int>>> d1 = new Dictionary<int, List<Tuple<int, int>>>();
var d2 = new Dictionary<int, List<Tuple<int, int>>>();

// 匿名类型,因为“匿名”
var a = new { A = 1, B = 2 };

// Linq,编译器推断比人工指定更合适,更方便
var student = Students.Where(s => s.Score >= 80).OrderBy(s => s.Id)

但也不是所有局部变量都适合用var,因为此时不好看出变量的类型,或编译器推断并不适合,比如

// 这个很好看出变量类型
var count = GetBooksCount();

// 遇到起名不规范的,就难办了
var result = DoSomeWork();
// 手工指定方便阅读
string result = DoSomeWork();

// 函数默认返回int,做除后结果为int
var num = GetNumber();
var result = num / 7;
// 手工指定double,做除后结果为double
double num = GetNumber();
var result = num / 7;

所以在保证能在阅读时清楚知道变量类型的情况下,应尽量使用var做到简洁明了;反之就要明确指出类型,比如面对int,float,double等时。

第2条 考虑使用readonly代替const

const是编译期常量,readonly是运行期常量。区别在于编译时,所有用到const常量的地方,该常量都会被常量实际值给完全替换,而用到readonly常量的地方仍会保留对readonly常量的引用,类似于

// 声明
const int a = 712;
readonly int b = 712;

// 使用
void DoSomeWork()
{
    Console.WriteLine(a);
    Console.WriteLine(b);
}

// 等价于
void DoSomeWork()
{
    // 在编译的时候,const常量就被替换成了值
    Console.WriteLine(712);
    // 仍引用了readonly常量
    Console.WriteLine(b);
}

问题在于在库A中声明了一常量const int num = 100;,若项目B引用了A的num,编译B时也会自动将B中用到的num自动替换为100。若后续库A将num更新为200,但项目B没有重新编译,则B中num仍为100。而改为readonly常量则没有这个问题,因为使用的时候引用的时真实变量,而不是编译时就写死的值。

因此只有在对性能极端敏感和有需求将值在编译期就固定下来的时候才使用const,不然最好选择readonly

第3条 优先考虑is或as运算符,尽量少用强制类型转换

is运算符用来判断变量是否兼容(支持转换成)某种类型,as运算符用来转换变量的类型。

object o = "66ccff";

// 强制转换
// 如果无法转换会抛出异常
try
{
    var s1 = (string)o;
    Console.WriteLine(s1);
}
catch (InvalidCastException e)
{
    Console.WriteLine(e);
    throw;
}

// is 判断类型是否兼容某类型
// null或不兼容返回false
if (o is string)
{
    string s2 = (string)o;
    Console.WriteLine(s2);
}

// as 用来转换变量的类型
// 转换失败返回null
string s3 = o as string;
if (s3 != null)
{
    Console.WriteLine(s3);
}

// 本书只涵盖到C#6.0
// C#7.0之后可以使用模式匹配的is语句
if (o is string s4)
{
    Console.WriteLine(s4);
}

因此我基本都是用第四种模式匹配的is语句来做类型转换,不过也有用as的时候,主要结合?.(null条件运算符),比如

// 模式匹配
if (o is Student student)
{
    student.UpdateScore(100);
}

// 可以替换成

// 若转换失败返回null
var student = o as Student;
// 若为null则不执行且无异常
student?.UpdateScore(100);

第4条 用内插字符串取代string.Format()

内插字符串也是C#6.0的特性之一,主要就是用来代替string.Format(),功能基本一致,使用时在字符串前面加上$符号即可,比如

var info1 = string.Format("学生姓名为{0},{1}岁", student.Name, student.Age);
// 用内插字符串代替
var info2 = $"学生姓名为{student.Name},{student.Age}岁";

可以看出内插字符串的可阅读性更好、更直观。且内插字符串功能丰富,在{}之间可以像原string.Format()一样用:格式说明符,也可以调用方法、使用null条件运算符、调用Linq等等。当然内插的表达式过于复杂就背离了我们使用内插字符串的初衷,不如先声明为局部变量再内插更直观。

// 使用Linq、null条件和合并运算符、格式说明符
var info1 = $"获得最高分的同学的生日为{(students.OrderByDescending(s => s.Score).FirstOrDefault()?.Birth ?? DateTime.MinValue):d}";

// 我觉得这样更好
var birthOfMaxScoreStudent = students.OrderByDescending(s => s.Score).
        FirstOrDefault()?.Birth ?? DateTime.MinValue;
var info2 = $"获得最高分的同学的生日为{birthOfMaxScoreStudent:d}";

第5条 用FormattableString取代专门为特定区域而写的字符串

FormattableString也是通过上一条的内插字符串生成的,主要是用来实现全球化和本地化。编译器在推断内插字符串类型的时候会默认推断为string,如有使用需要需要手工指定为FormattableString类型

// date1为string类型
var date1 = $"{DateTime.Now.Date}";
// date2为FormattableString类型
FormattableString date2 = $"{DateTime.Now.Date}";

然后需要编写一个方法用来将FormattableString转换为适应不同国家和地区的字符串,比如

// 将FormattableString转换为针对中国大陆地区的风格的字符串
public static string ToCN(this FormattableString src)
{
    return string.Format(null, System.Globalization.CultureInfo.CreateSpecificCulture("zh-cn"),
        src.Format,src.GetArguments());
}

第6条 不要用表示符号名称的硬字符串来调用API

某些地方会使用到变量的名字,比如WPF实现INotifyPropertyChanged接口的时候,需要以字符串形式传入对应变量的名字实现绑定,如果直接用"Name"这种字符串常量写死会导致强耦合,一旦后来代码修改时更改了变量名字,那么就会出现问题且IDE没有提示。所以需要用到nameof()运算符,可以获取类型、变量、接口、命名空间的名字。

// WPF实现MVVM的属性绑定
public class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged(string propertyName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

public class Student : ViewModelBase
{
    private string _name;

    public string Name
    {
        get => _name;
        // 用nameof()运算符
        // set { _name = value; OnPropertyChanged("Name"); }
        set { _name = value; OnPropertyChanged(nameof(Name)); }
    }
}


还有一点题外话(点击展开)

// 题外话:针对类似场景可以使用CallerMemberName这个Attribute
public class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    // CallerMemberName
    protected virtual void OnPropertyChanged([CallerMemberName]string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}


public class Student : ViewModelBase
{
    private string _name;

    public string Name
    {
        get => _name;
        // 调用OnPropertyChanged()时会自动传入调用者的名字
        set { _name = value; OnPropertyChanged(); }
    }
}

// 再次题外话:实现MVVM还是直接用库比如MVVM Toolkit就好,简单易用
using Microsoft.Toolkit.Mvvm.ComponentModel;
using Microsoft.Toolkit.Mvvm.Input;

public class Student : ObservableRecipient
{
    private string _name;

    public string Name
    {
        get => _name;
        set => SetProperty(ref _name, value);
    }
}

第7条 用委托表示回调

C#中用委托表示回调,委托有点类似函数指针的意思,简单地说就是把需要使用的方法传给其他地方进行调用。比如我跟厨师说到11点做西红柿炒蛋,做西红柿炒蛋()是一个方法,我将其包装成委托,传给厨师,厨师到了合适的时候就会根据我的委托回调做西红柿炒蛋()这个方法。

原先委托用delegate声明,不过现在一般直接用内置的Action<T>Func<T>,区别在于Action<T>无返回值而Func<T>有返回值。比如

// 用delegate声明
delegate void SimpleDelegate(string text);
SimpleDelegate simpleDelegate = Console.WriteLine;

// Action<T>
// Action<T>的所有T均为输入类型
Action<string> simpleAction = Console.WriteLine;

// Func<T>
public static int Add(int x, int y)
{
    return x + y;
}
// Func<T>的最后一个T为返回类型,前面的T均为输入类型
Func<int, int, int> simpleFunc = Add;
// 当然也可以用lambda表达式一步到位
Func<int, int, int> simpleFunc2 = (x, y) => x + y;

// Predicate<T>,不常见
// 类似于Func<T, bool>,但不等价
// 有且只能有一个输入参数
Predicate<int> simplePredicate = x => x == 1;

委托很常见,比如Linq其实就利用了委托,比如

var count = students.Count(s => s.Score >= 90);
// 其实是编译器自动将lambda表达式的匿名函数转换为了委托
Func<Student, bool> over90Func = s => s.Score >= 90;
var count = students.Count(over90Func);

第8条 用null条件运算符调用事件处理程序

简单来说就是触发事件因为线程不安全,很有可能造成NullReferenceException,而利用null条件运算符可以用很安全且简洁的方式调用事件处理,我在第6条折叠的题外话中使用过。

// 详细见第6条题外话代码
public class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName]string propertyName = null)
    {
        // 不要用PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        // Invoke()是委托和事件自动生成的方法,等于调用其自身
        // 语法不支持PropertyChanged?.(),所以要用PropertyChanged?.Invoke()
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

第9条 尽量避免装箱与取消装箱这两种操作

老生常谈的问题了,装箱和取消装箱(拆箱)的详细定义见微软官方文档《装箱和取消装箱(C# 编程指南)》

简单来说装箱是将值类型转换为引用类型,也就是将值类型包装用object类型包装,并将其存储在托管堆中。取消装箱则相反,是从引用类型对象中提取出值类型。装箱是隐式的,取消装箱是显式的。

int i = 123;
// 装箱(隐式)
object o = i;
// 拆箱(显式)
int j = (int)o;

装箱与取消装箱的问题在于频繁进行这两种操作会严重影响性能,因为每次装箱,都要在托管堆中生成一个拷贝值类型的对象,取消装箱则是根据托管堆中的对象生成一个值类型拷贝,如下图所示

官方文档里说:

如果值类型必须被频繁装箱,那么在这些情况下最好避免使用值类型(例如在诸如 System.Collections.ArrayList 的非泛型集合类中)。可通过使用泛型集合(例如 System.Collections.Generic.List<T>)来避免装箱值类型。装箱和取消装箱过程需要进行大量的计算。对值类型进行装箱时,必须创建一个全新的对象。这可能比简单的引用赋值用时最多长 20 倍。取消装箱的过程所需时间可达赋值操作的四倍。

不过感觉日常使用其实装箱和取消装箱的操作还是比较少的,毕竟现在的集合应该都是支持泛型的,基本没有放弃List<T>而使用ArrayList的情况吧。我印象里最近使用过,不得不装箱的场景就是WPF中实现基于IValueConverter接口的转换器,输入和输出都是object类型,如果涉及到值类型肯定不可避免产生装箱和取消装箱,但就这个场景来说可能对性能没有那么敏感。

// int类型和枚举类型的转换
public class VariantTypeToIntConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        return (int)(VariantType)value;
    }

    public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        return (VariantType)(int)value;
    }
}

第10条 只有在应对新版基类与现有子类之间的冲突时才应该使用new修饰符

子类继承父类时,可以用new修饰符隐藏父类的成员和方法(包含虚和非虚):

public class Parent
{
    public void Print()
    {
        Console.WriteLine(nameof(Parent));
    }
}

public class Child : Parent
{
    // 不加new也不报错,但编译器会提醒若是有意隐藏父类成员请加new
    public new void Print()
    {
        Console.WriteLine(nameof(Parent));
    }
}

但是测试会发现这种情况:

Child child = new Child();
Parent parent = new Child();
child.Print();
parent.Print();

// 输出
// Child
// Parent

childparent的运行时类型都为Child,但parent的编译时类型为Parent,在调用时只会调用其编译时类型中的方法,而不考虑其实际运行时类型中的同名new修饰的方法。

作为对比,override修饰符则会动态搜寻其运行时类型向上所能接触到的最近的方法。

public class Parent
{
    public virtual void Print()
    {
        Console.WriteLine(nameof(Parent));
    }
}

public class Child : Parent
{
    // override用来覆写虚方法
    public override void Print()
    {
        Console.WriteLine(nameof(Child));
    }
}

测试输出:

Child child = new Child();
Parent parent = new Child();
child.Print();
parent.Print();

// 输出
// Child
// Child

书中建议,只有当你基于现有父类A的子类B包含方法X,而新版的A中添加了和B中同名的X时,可以让你的B中的X添加new修饰符作为暂时的解决方案,当然就长远来看最好还是考虑改名。而不应该把new当作override的替代品用于覆写父类中非虚的方法。这也需要父类的设计者应该认真考虑清楚什么方法可能和应该交由子类重新实现,然后用virtual修饰。

第一章完。

如果觉得我的文章对你有用,请随意赞赏