张天昀的个人博客

C#编程语言学习笔记

2020年06月07日

数据类型

C#是强类型语言。C#的类型分为值类型和引用类型两种,值类型的数据可以分配在栈(局部变量)或堆(new运算符)上;引用类型的数据全部在堆区。

C#内置的数据类型:

  • 整型
    • bool
    • sbyte/byte:1字节
    • short/ushort:2字节
    • int/uint:4字节
    • long/ulong:8字节
    • char:2字节
  • 浮点
    • float:4字节
    • double:8字节
    • decimal:16字节
  • 字符串:string(字符串是按值传递的,需要按引用可以用StringBuilder
  • 数组:type[]
  • 集合(全部为引用传递):
    • System.Collections.Generic:
      • Dictionary<TKey, TValue>
      • List<T>
      • Queue<T>
      • SortedList<TKey, TValue>
      • Stack<T>
    • System.Collections.Concurrent: ConcurrenctXXX<>
    • System.Collections: collections of Object. Use generic collections or concurrent collections whenever possible.
      • ArrayList
      • Hashtable
      • Queue
      • Stack

函数调用时,值类型数据pass by value,引用类型数据pass by reference。通过refout关键字可以强制值类型数据按引用传递,其区别是ref需要对参数进行初始化,即相当于C语言中的传指针让被调用者修改值;而out不需要对参数进行初始化,相当于C语言中分配了一个变量让被调用者把数据存放进去。

对类型进行转换可以用C风格的强制类型转换,或者用C#的as运算符。as运算符会对对象进行检查,如果类型转换不能发生,则转换的结果为null

类和结构体

在C#中所有的class都是引用类型,所有的struct都是值类型。在泛型中可以用where T : classwhere T : struct来限制参数的类型。

类和结构体都可以有构造函数,结构体一定会被编译器提供一个默认构造函数(无参数),这个构造函数在用new运算符创建结构体时会被调用。对于类而言,非静态的、没有任何构造函数的类才会被提供一个默认构造函数。

如果不希望类被实例化,可以将构造函数设置为私有(和C++一致)。

C#的构造函数可以通过basethis关键字调用基类、当前类的其他构造函数,语法为:

public Derived(int value) : base(value) {}
public Derived(int value) : this(value, value) {}

类还可以有终结器函数(析构函数),当GC回收对应对象内存之前会被调用来清理所占有的资源(因此需要两次GC,第一次GC将对象加入FReachableList队列中并调用终结器,第二次GC销毁内存单元)。

结构体不能有终结器,类不能显式调用终结器函数(与C++可以手动析构不同)。终结器函数会隐式地调用基类的Finalize方法,回收基类的资源。

C#的类支持多态:virtualabstract关键字用于定义虚函数,abstract定义的函数在基类中必须实现。override关键字表明函数是对基类方法的重载;sealed关键字用于消除虚函数属性,此函数在继续派生的类中不可以再被重载。

C#中还有new关键字可以用于重定义基类的成员或方法。重定义后基类的成员或方法就会被派生类的覆盖,要想调用基类的方法可以通过base关键字来访问。

范例代码:

public class Base
{
    public virtual void Hello()
    {
        Console.WriteLine("Hello from base class");
    }
}

public class Derived : Base
{
    public sealed override void Hello()
    {
        Console.WriteLine("Hello from derived class");
    }
    public void HelloFromBaseClass()
    {
        base.Hello();
    }
}

public sealed class MoreDerived : Derived
{
    public new void Hello()
    {
        Console.WriteLine("Hello from more derived class");
    }
    public void HelloFromDerived()
    {
        base.Hello();
    }
}

泛型可变性、协变和逆变

根据里氏替换原则,派生类对象可以在程序中代替其基类对象,所以可以把派生类对象赋值给基类对象(如把string类对象赋值给object类对象)。协变保留了这种赋值关系,而逆变则反转这种赋值关系。如派生类变为派生程度更高的类是协变,变为基类是逆变。

  • 协变(Covariance):能够使用派生程度更大的类型
    • IEnumerable<>的参数是协变的
    • IEnumberable<string>可以赋值给IEnumerable<object>
    • 加入的元素一定具有基类的迭代器,所以协变是安全的
  • 逆变(Contravariance):能够使用派生程度更小的类型
    • Action<>的参数是逆变的
    • Action<object>可以赋值给Action<string>
    • 调用委托时,派生类一定具有基类的方法,所以逆变是安全的
  • 不变(Invariance):只能使用指定的类型
    • List<string>只接受List<string>

协变和逆变的特性:

  • 协变用于输出的类型,函数返回的派生类具有基类所有的数据成员和方法,那么把派生类实例化出来的泛型对象赋值给基类实例化出来的对象是安全的。所以C#中用out来标记协变参数。
  • 逆变用于输入的类型,如接受基类参数的泛型委托只能调用基类的成员和方法,那么把它赋值给派生类实例化的委托对象是安全的(因为所有的派生类都具有调用的成员和方法)。所以C#中用in来标记逆变参数。
// 动物有性别属性和睡觉方法
public class Animal
{
    public bool isMale { get; private set; }
    public void Sleep();
}

// 狗继承自动物,狗有自己的其他成员
public class Dog : Animal
{
    public void Bark();
}

// 协变:基类向派生类转换
// 通过基类集合访问元素时,不可能访问到派生类特有的成员
IEnumerable<Animal> animals = new List<Dog>;
foreach(var animal in animals)
{
    if (animal.isMale)
    {
        // 所有的狗都有isMale属性,所以是安全的
    }
    // animal.Bark(); // 不是所有动物都有Bark方法
}

// 逆变:派生类向基类转换
// 首先定义一个接受基类成员的委托,然后把它赋值给派生类成员的委托
// 因为委托只接受基类成员,所以委托不可能调用基类没有的成员/方法
Action<Animal> animalSleep = (animal) => { animal.Sleep(); };
Action<Dog> dogSleep = animalSleep;
dogSleep(new Dog()); // 所有的狗都有Sleep方法,所以是安全的

委托和事件

委托类似于C/C++中的函数指针,设计思路就是把某件要做的事情交给别人去做,至于交给谁去做只有运行的时候才会知道(晚绑定)。

例如一个反转字符串的委托:

public delegate string Reverse(string s);
Reverse = ReverseStringImpl;

可以将任何接受string参数、返回string值的函数作为委托的实现,对Reverse进行赋值。赋值后即可通过Reverse(s)进行调用委托。

C#中内置了三种常用的委托类型:

  • Action<>:执行某种操作,本质上是delegate void
  • Func<>:执行某种变换,如上文的Reverse可以表示为Func<string, string>
  • Predicate<>:进行某种逻辑判断的谓词,本质上是Func<T, bool>

如果委托的实现在其他地方都不使用,那么可以用Lambda表达式来实现。Lambda表达式也被用于LINQ查询中实现谓词或进行投影。

事件的类型为EventHandler<>,本质上是委托进行实现的,如

public event EventHandler<FileListArgs> Progress;
Progress += OnProgressXXXX;
Progress?.Invoke(this, new FileListArgs(file));

事件处理函数必须返回void。当要创建事件时,可以通过?.运算符调用Invoke方法,调用所有订阅该事件的处理函数。

对委托进行组播可以用Combine+=/-=运算符;对事件进行订阅可以用+=运算符,对事件进行退订可以用-=运算符。

委托和事件进行比较:

  • 相似点
    • 都实现了晚绑定;
    • 都支持单播/组播(单订阅/多订阅);
    • 增减处理函数的语法相似;
    • 发起事件和调用委托的函数调用语法完全相同;
    • 都支持Invoke()方法。
  • 不同点
    • 事件可以无人监听,而委托必须有具体实现;
    • 事件处理函数不可以有返回值,而委托可以;
    • 事件处理函数只有能够发起事件的类可以调用,而委托可以被各种地方调用;
    • 事件处理函数的生命周期往往更长(委托实现并作为参数传递之后就不用了)。

垃圾回收

C#的CLR进行垃圾回收的条件:

  • 系统物理内存不足;
  • CLR管理的堆区中分配的对象所占内存超过了阈值;
  • GC.Collect()方法被调用。

当进行垃圾回收时,所有健康的对象都会被移动到一起,压缩内存空间。CLR管理的堆区可以被认为有两块:分别存储大对象和小对象,对象大小的阈值可以自己设定。

C#的垃圾回收有“代”的概念,所有在堆区中的对象被划分为0-2三代之一。

  • 0代
    • 所有新分配的内存单元都被划分到0代;
    • 但如果对象巨大则会被分配到大对象堆并标记为3代(实现中作为生命周期长的对象,3代视作是2代的一部分);
    • 大部分垃圾在0代就会被收回,不会存活到下一代。
  • 1代:
    • 对0代进行垃圾回收后,所有存活的对象被压缩并移动到1代的队列中;
    • 每次对0代进行垃圾回收不会检查1、2代的对象;
    • 只有当0代进行垃圾回收后内存仍然不足,才会对1、2代进行GC,此时1代中存活的对象会被移动到2代。
  • 2代:
    • 存放生命周期长的对象和大对象。

每次GC时,如果某个代中存活的对象所占比例很高,那么GC就会提高那一代分配内存的阈值。CLR会不断平衡垃圾回收的频率,既不能回收地太慢导致内存占用过大,也不频繁地回收增加开销。

如果对象有非CLR托管的资源,如文件、网络等,那么GC可能会导致内存泄漏(CLR不关心非托管资源)。解决方案是实现Dispose方法,并主动调用它来手动归还资源。同时需要实现SafeHandle或重载Finalize方法来防止用户忘了调用Dispose,此时GC就会在回收的时候帮用户去调用终结函数。另外,实现了Dispose后,用户可以用using关键词来让使用后自动释放对应的资源。