前言

这篇文章将原来的 C# 学习笔记(1) 到 (19) 合并为一篇连续阅读稿,方便从 C# 与 .NET Framework 的基本认识一路读到类型系统、面向对象、泛型、LINQ、多线程、异步与文件操作。内容仍按原来的学习顺序展开,但做了统一整理,让整篇更像一份可以顺着读完的阶段性笔记。

C# 与 .NET Framework 起步

什么是 C#

C# 是微软开发的一门面向对象高级程序设计语言,运行在 .NET Framework 提供的执行环境之上。因为背靠 .NET Framework,C# 拥有完整的基础类库、运行时能力以及大量现成控件和开发技术,所以既能写桌面应用,也能写 Web 应用和服务端程序。

C# 的发展历程

理解一门语言,最好先理解它是怎么长大的。C# 从 2000 年发布开始,几乎每个版本都围绕一条明确主线进行演进,比如委托、泛型、Lambda、动态类型和异步编程。

C# 版本.Net Framework 版本Visual Studio 版本发布日期代表特性
C# 1.0.Net Framework 1.0Visual Studio .Net 20022002.1委托、事件
C# 1.1.Net Framework 1.1Visual Studio .Net 20032003.4APM
C# 2.0.Net Framework 2.0Visual Studio 20052005.11泛型、匿名方法、迭代器、可空类型
C# 3.0.Net Framework 3.0 / 3.5Visual Studio 20082007.11var、对象集合初始化器、自动属性、匿名类型、扩展方法、查询表达式、Lambda、表达式树、LINQ
C# 4.0.Net Framework 4.0Visual Studio 20102010.4动态绑定、命名与可选参数、泛型协变与逆变、互操作性
C# 5.0.Net Framework 4.5Visual Studio 20122012.8异步与等待

下图更直观地展示了不同版本的主题演进:

C# 版本主题演进图

C# 可以做什么

这里只保留最常见的三类场景。

Windows 桌面应用程序

在较早阶段,可以使用 WinForms 开发桌面应用;在后续版本里,也可以使用 WPF 构建更灵活、界面表现更强的桌面程序。

Windows 桌面应用程序示意图

Web 应用程序

.NET Framework 提供了 ASP.NET 技术栈,可以帮助我们快速创建 Web 应用。

Web 应用程序示意图

Web 服务

在 .Net Framework 3.0 之后,微软提供了 WCF 来实现 Web 服务,它曾是分布式应用开发中的重要方案。

Web 服务示意图

什么是 .NET Framework

很多初学者容易把 C# 和 .NET Framework 混在一起。其实二者关系很简单:

  • C# 是编程语言。
  • .NET Framework 是程序运行时执行环境和基础设施。

.NET Framework 提供的核心能力主要包括:

  • 全面的类库
  • 内存管理
  • 通用类型系统
  • 丰富的开发技术与框架
  • 语言互操作能力

.NET Framework 的组成

公共语言运行时(CLR)

CLR 是 .NET Framework 的核心基础。它可以被理解为托管代码的“运行代理”,负责内存管理、线程调度、异常处理和类型安全校验。受其管理的代码称为托管代码,不受其管理的代码则是非托管代码。

CLR 又包含两个重要部分:

  • CTS:通用类型系统
  • CLS:公共语言规范

前者帮助不同语言在类型层面达成统一,后者帮助不同语言在规范层面更好协作。

基础类库(BCL / FCL)

.NET Framework 类库本质上是一组程序集的集合,里面定义了大量可复用类型。不同类型会按功能归类到不同命名空间中,例如 System.IO 用于输入输出操作。

.NET Framework 基础类库示意图

C# 代码执行过程

执行过程可以概括为两步:

  1. C# 源代码被编译成中间语言(IL)
  2. CLR 再通过 JIT 把中间语言编译成本机代码执行

所以,写 C# 时你面对的是高级语言语法,但真正执行时还要依赖 .NET 运行时提供的整套基础设施。

语言基础

标识符与关键字

任何编程语言都离不开“命名”和“保留词”这两件事。

标识符

标识符就是程序员自己起的名字,例如类型名、方法名、变量名、参数名等。下面代码中的 Demo、Program、Main、args、text 都是标识符。

namespace Demo
{
    class Program
    {
        static void Main(string[] args)
        {
            string text = "Welcome";
            Console.WriteLine(text);
            Console.ReadKey();
        }
    }
}

C# 是大小写敏感的,因此 hello 和 Hello 会被认为是两个不同标识符。

关键字

关键字是语言本身保留的单词,例如 namespace、class、void。它们通常不能直接拿来命名,但可以通过在前面加 @ 的方式作为标识符使用,这在字段名与关键字冲突的场景中偶尔会用到。

数据类型

在通用类型系统(CTS)中,类型大体分成两类:值类型和引用类型。

  • 值类型:数值类型、布尔类型、枚举类型、结构体类型
  • 引用类型:数组、类、接口、委托、object 等

数值类型

数值类型可以继续分成整型、浮点型和十进制型:

  • 整型:int、long、short、byte、char
  • 浮点型:float、double
  • 十进制型:decimal

其中 decimal 精度高、范围相对小,常用于财务和货币计算。

布尔类型

bool 用于表示逻辑值 true 和 false。

字符串类型

string 是 System.String 的别名,表示由零个或多个字符组成的不可变序列。

class Program
{
    static void Main(string[] args)
    {
        string text = "Welcome";
        text = "Hi";
        Console.WriteLine(text);
        Console.ReadKey();
    }
}

这里表面上像是在“修改字符串”,实际上是让变量重新指向了一个新字符串对象。这就是字符串的不可变性。正因为如此,如果频繁拼接和修改字符串,就可能带来额外内存开销。

枚举类型

枚举是一组具名常量,本质上属于值类型。

enum Gender
{
    Female,
    Male
}

也可以显式指定其底层类型:

enum Gender : byte
{
    Female,
    Male
}

结构体

结构体是一种值类型,常用于表示轻量级数据对象。

public struct Point
{
    public int X;
    public int Y;

    public Point(int px, int py)
    {
        X = px;
        Y = py;
    }
}

数组类型

数组是一种引用类型,表示同类型元素的集合。

变量与常量

变量表示一块可读写的内存空间,常量则在赋值后不能再改变。

变量

变量的基本语法就是:数据类型 变量名。

int a, b, c;

如果局部变量声明后没有初始化就直接使用,编译器会报“使用了未赋值的局部变量”。

变量的作用域

作用域决定变量的生命周期和可访问范围。C# 中常见的是函数作用域和块作用域。

public static void TestMethod()
{
    string[] foo = new string[3] { "1", "2", "3" };

    foreach (string item in foo)
    {
        Console.WriteLine(item);
    }

    // 这里访问不到 item
    Console.WriteLine(item);
}

常量

常量用 const 定义:

const int MaxCount = 100;

需要注意:

  • 常量必须在声明时初始化
  • 常量不能接受变量赋值
  • 常量默认是静态的,不能再显式加 static

类和结构体

什么是类

类代表一组具有公共属性和行为的对象。比如“人”这个概念,可以抽象出姓名、年龄等属性,以及说话、行走等行为。

class Person
{
}

通过访问修饰符,可以控制类成员的可见范围:

修饰符访问权限
private仅当前类内部可访问
public当前程序集及引用程序集都可访问
protected当前类和派生类可访问
internal当前程序集内可访问
protected internal当前程序集或派生类可访问

类的常见成员

类中最常见的成员包括字段、属性、方法、构造函数、析构函数和索引器。

字段与属性

字段保存数据,属性用于在封装的基础上对字段进行读写控制。

public class Person
{
    private string name;

    public string Name
    {
        get
        {
            return name;
        }
        set
        {
            name = value;
        }
    }
}

如果字段不希望被随意修改,可以用 readonly 或 const:

public class Person
{
    private readonly string name;
    public const int DefaultAge = 18;
}

方法

方法由方法签名和方法体组成,用于定义类型的行为。

public class Person
{
    public void Speak(string msg)
    {
        Console.WriteLine(msg);
    }
}

构造函数与静态构造函数

实例构造函数用于初始化对象,静态构造函数用于初始化静态成员。

public class Person
{
    private static string siteName;
    private string name;

    static Person()
    {
        siteName = "Demo";
    }

    public Person()
    {
        name = "Carl";
    }
}

析构函数

析构函数用于释放对象占用的资源,但现代 C# 开发中更常见的是 IDisposable 模式。

public class Person
{
    ~Person()
    {
        Console.WriteLine("析构函数被调用了");
    }
}

类与结构体的区别

类和结构体在语法上很像,但本质不同:

  • 类用 class 定义,结构体用 struct 定义。
  • 类是引用类型,结构体是值类型。
  • 类可以继承,结构体不能继承类或结构体。
  • 结构体不能定义析构函数。
  • 类通常分配在托管堆上,结构体通常按值复制。
public class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

public struct Point2
{
    public int X { get; set; }
    public int Y { get; set; }
}

一般来说,数据量小、语义上更接近“值”的对象适合用结构体;需要继承、共享状态或更复杂行为时更适合用类。

面向对象基础

C# 是面向对象语言,最核心的三个特性是封装、继承和多态。

封装

封装的目的,是隐藏对象内部状态,只暴露安全的访问方式。

public class Person
{
    private int _age;

    public int Age
    {
        get
        {
            return _age;
        }
        set
        {
            if (value < 0 || value > 99)
            {
                throw new Exception("年龄必须在 0 到 99 之间");
            }

            _age = value;
        }
    }
}

继承

子类可以继承父类已有的成员,从而减少重复代码。C# 只支持单继承,但允许实现多个接口。

public class Animal
{
    public int Age { get; set; }
}

public class Horse : Animal
{
}

public class Sheep : Animal
{
}

当初始化子类时,执行顺序通常是:

  1. 初始化实例字段
  2. 调用基类构造函数
  3. 调用子类构造函数

多态

多态是指相同类型的对象调用相同方法时,表现出不同的行为。常见实现方式是虚方法与重写。

public class Animal
{
    public virtual void Voice()
    {
        Console.WriteLine("动物发出叫声");
    }
}

public class Cat : Animal
{
    public override void Voice()
    {
        Console.WriteLine("喵~");
    }
}

public class Sheep : Animal
{
    public override void Voice()
    {
        Console.WriteLine("咩~");
    }
}

抽象类、密封类与成员隐藏

  • abstract 可以定义抽象类,阻止直接实例化。
  • sealed 可以阻止类继续被继承,或阻止虚成员被继续重写。
  • new 可以隐藏基类同名成员,但它与 override 并不一样。

override 是多态,new 是隐藏;两者输出结果可能完全不同。

所有类的根:System.Object

所有类最终都派生自 System.Object。因此,每个对象默认都具备 Equals、GetHashCode、GetType 和 ToString 等基础能力。

接口

接口可以理解为“行为约定”。它声明一组成员,但不提供具体实现,实现接口的类型必须补上这些成员。

interface ICustomCompare
{
    int CompareTo(object other);
}

class Person : ICustomCompare
{
    public int Age { get; set; }

    public int CompareTo(object other)
    {
        if (other == null)
        {
            return 1;
        }

        Person p = (Person)other;
        if (Age < p.Age)
        {
            return -1;
        }
        if (Age > p.Age)
        {
            return 1;
        }

        return 0;
    }
}

显式接口实现

当一个类实现多个包含同名成员的接口时,显式接口实现特别有用。

interface IChineseGreeting
{
    void SayHello();
}

interface IAmericanGreeting
{
    void SayHello();
}

public class Speaker : IChineseGreeting, IAmericanGreeting
{
    void IChineseGreeting.SayHello()
    {
        Console.WriteLine("你好");
    }

    void IAmericanGreeting.SayHello()
    {
        Console.WriteLine("Hello");
    }
}

这种方式的重点是:接口成员只能通过接口引用访问,而不能直接通过类实例访问。

委托与事件

委托是什么

委托可以看作“类型安全的函数指针”,本质上是一个类,用来封装方法。

public delegate void OutputAction(string msg);

委托的一个重要用途,是把方法当成参数传递。

public class Logger
{
    public OutputAction Output;

    public Logger()
    {
        Output = msg => Console.WriteLine(msg);
    }

    public Logger(OutputAction outputAction)
    {
        Output = outputAction;
    }

    public void Debug(string msg)
    {
        Output(msg);
    }
}

多播委托

委托可以通过 + 把多个方法串成调用链,也可以通过 - 移除某个方法。

logger.Output += msg => Console.WriteLine(msg);
logger.Output += msg => Console.WriteLine($"{DateTime.Now}: {msg}");
logger.Output -= myDelegate;

事件是什么

事件是建立在委托基础上的语言特性。event 的核心价值在于:限制外部代码只能订阅和取消订阅,而不能随意触发事件。

public delegate void PublishHandler(string name);

public class AuthorBlog
{
    public event PublishHandler Publish;

    public void OnPublishing(string name)
    {
        Console.WriteLine("作者发布了:" + name);
        Publish?.Invoke(name);
    }
}

EventHandler

实际开发中,更常见的是使用框架提供的 EventHandler 和自定义 EventArgs。

public class BlogAuthorEventArgs : EventArgs
{
    public string Name { get; set; }

    public BlogAuthorEventArgs(string name)
    {
        Name = name;
    }
}

这种写法更符合 .NET 生态的习惯,也便于统一事件签名。

深入理解类型

值类型与引用类型

C# 类型大体可以分为两类:

  • 值类型:如 int、bool、enum、struct
  • 引用类型:如 class、interface、delegate、string、数组

值类型变量通常直接保存数据本身;引用类型变量保存的是对象引用。

装箱与拆箱

把值类型转换为引用类型,叫装箱;反过来则叫拆箱。

int i = 3;
object o = i;    // 装箱
int y = (int)o;  // 拆箱

装箱和拆箱都存在额外开销,因此在泛型可以解决问题的场景中,应尽量避免频繁发生这类转换。

参数传递

C# 默认按值传递参数,但“值传递”分成两种常见情况:

  • 值类型按值传递:传的是副本
  • 引用类型按值传递:传的是引用副本

此外,还可以使用 ref 和 out 实现按引用传递。

private static void ChangeByValue(ref int numValue)
{
    numValue = 10;
}

string 虽然是引用类型,但由于不可变性,很多时候它表现得更像“值”。

泛型与 C# 2.0 的核心能力

泛型

泛型的意义,在于把“算法”从“具体类型”中抽离出来,让代码更通用、更安全,也更高效。

public class Compare<T> where T : IComparable<T>
{
    public static T Max(T t1, T t2)
    {
        return t1.CompareTo(t2) > 0 ? t1 : t2;
    }
}

泛型的优势主要有三点:

  • 减少重复代码
  • 避免不必要的装箱和拆箱
  • 在编译期保证类型安全

类型参数约束

通过 where 可以限制类型参数的范围:

  • where T : class
  • where T : struct
  • where T : new()
  • where T : 某个基类或接口
private static T Max<T>(T obj1, T obj2) where T : IComparable<T>
{
    return obj1.CompareTo(obj2) > 0 ? obj1 : obj2;
}

可空类型

可空值类型本质上是 Nullable<T> 的语法糖。

int? number = null;
Console.WriteLine(number ?? 2);

?? 空合并运算符可以在左侧为空时返回右侧默认值,常用于简化判空逻辑。

匿名方法与迭代器

匿名方法后来逐渐演化为 Lambda 表达式;迭代器则通过 yield return 极大简化了 IEnumerator 的实现。

public IEnumerator GetEnumerator()
{
    for (int i = 0; i < friends.Length; i++)
    {
        yield return friends[i];
    }
}

C# 3.0:让代码更简洁

自动属性与 var

自动属性减少了样板代码,var 则把类型推断交给编译器。

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}

var p = new Person { Name = "Carl", Age = 20 };

对象初始化器、集合初始化器与匿名类型

var list = new List<string> { "Carl", "Karl" };
var anonymous = new { Name = "Carl", Age = 20 };

这些特性让数据创建过程更短、更直观。

Lambda 表达式

Lambda 表达式本质上是更简洁的匿名方法写法。

Func<string, int> getLength = text => text.Length;

它大量用于事件订阅、集合操作和表达式构建。

表达式树

Lambda 表达式不仅可以编译成委托,也可以转为表达式树,以树结构描述代码逻辑。

Expression<Func<int, int, int>> expression = (a, b) => a + b;
Func<int, int, int> del = expression.Compile();

表达式树是后续 LINQ to SQL 等能力的基础之一。

扩展方法

扩展方法让我们可以在不修改原类型的前提下,为现有类型添加“看起来像实例方法”的能力。

public static class IEnumerableExtensions
{
    public static bool IsEmpty<T>(this IEnumerable<T> ts)
    {
        return ts == null || !ts.Any();
    }
}

调用时很自然:

List<string> list = new List<string>();
Console.WriteLine(list.IsEmpty());

扩展方法虽然方便,但应尽量扩展具体类型,而不是一股脑扩展 object,否则容易造成 API “污染”。

LINQ

LINQ 是 Language Integrated Query,即语言集成查询。它把查询能力直接带入 C# 语法,使查询集合、XML 甚至数据库时都能保持统一风格。

查询集合

var queryResults = from item in collection
                   where item % 2 == 0
                   select item;

与传统 foreach 加 if 相比,LINQ 的表达更集中,也更容易组合多个筛选条件。

查询 XML

XElement xElement = XElement.Parse(xml);

var queryResults = from el in xElement.Elements("Person")
                   where el.Element("Name")?.Value == "李四"
                   select el;

LINQ to XML 比手写 XPath 或层层遍历更直观。

LINQ 的本质

对于编译器来说,查询表达式最终仍会被翻译成方法调用,因此理解 Where、Select、OrderBy 等方法的本质很重要。

C# 4.0 与动态能力

可选参数与命名实参

static void Test(int x, int y = 10, string name = "")
{
    Console.WriteLine($"{name}:{x},{y}");
}

Test(10, name: "Hello");

这种写法在参数较多时可读性不错,也减少了重载数量。

泛型的协变与逆变

  • 协变:更具体的类型可以当作更通用的类型返回,常见于 out
  • 逆变:更通用的类型可以用于更具体的参数输入,常见于 in

这使得接口和委托在泛型场景中更灵活。

动态类型 dynamic

dynamic 会把类型检查延后到运行时。

dynamic i = 10;
i = i + 10;

它能减少某些强制类型转换,也能与动态语言或运行时对象模型协作,但代价是编译期类型安全下降。

动态行为的实现方式

在 .NET 中,可以通过以下方式实现动态行为:

  • ExpandoObject
  • DynamicObject
  • IDynamicMetaObjectProvider
dynamic d = new ExpandoObject();
d.Name = "测试";
d.AddMethod = (Func<int, int>)(x => x + 1);
Console.WriteLine(d.AddMethod(5));

多线程编程

进程与线程

进程可以理解为资源容器,线程则是其中的执行单元。一个进程至少有一个主线程。

前台线程与后台线程

当前台线程全部结束后,CLR 会直接终止仍在运行的后台线程。

Thread backThread = new Thread(Worker);
backThread.IsBackground = true;
backThread.Start();

如果希望主线程等待后台工作完成,可以使用 Join。

线程池

线程的创建和销毁成本较高,因此 .NET 提供了线程池来复用线程。

ThreadPool.QueueUserWorkItem(CallBackWorkItem);
ThreadPool.QueueUserWorkItem(CallBackWorkItem, "work");

线程池线程默认是后台线程,适合处理短任务。

协作式取消

CancellationTokenSource cts = new CancellationTokenSource();
ThreadPool.QueueUserWorkItem(CallBack, cts.Token);
cts.Cancel();

取消不是“强行掐断”,而是通过 CancellationToken 让任务主动感知并停止。

线程同步

多线程访问共享资源时,需要同步机制来保证数据正确性。

static readonly object globalObj = new object();

lock (globalObj)
{
    Console.WriteLine("安全访问共享资源");
}

底层常见同步工具是 Monitor,而 lock 是更简洁的语法糖。线程同步虽然必要,但也会带来复杂度和性能成本,因此应尽量减少共享状态。

异步编程

异步编程适用于下载文件、读取网络资源等耗时任务,目的是避免阻塞主线程,提升用户体验。

同步方式的问题

如果在 UI 线程直接执行耗时操作,界面会卡住,用户无法继续操作。

APM:Begin / End

早期 .NET 使用异步编程模型(APM),常见形式是 BeginXxx / EndXxx。

IAsyncResult result = httpWebRequest.BeginGetResponse(null, null);
httpWebResponse = (HttpWebResponse)httpWebRequest.EndGetResponse(result);

这种模式已经能实现异步,但写法繁琐,而且如果在 UI 线程错误地调用 EndXxx,仍可能把界面堵住。

SynchronizationContext

在桌面应用中,后台线程不能直接操作 UI 控件,通常要借助 SynchronizationContext 把结果切回 UI 线程。

async / await

在 .NET 4.5 之后,async / await 成为更自然的异步写法。

private async Task DownloadFileAsync(string url)
{
    HttpWebRequest httpWebRequest = (HttpWebRequest)WebRequest.Create(url);
    HttpWebResponse httpWebResponse = (HttpWebResponse)await httpWebRequest.GetResponseAsync();
}

async / await 不一定会新建线程,但它能把“等待”这件事从“阻塞线程”变成“让出线程”,因此在 Web 和 UI 场景里都很重要。

文件操作

文件与目录相关核心类

最常用的文件系统 API 位于 System.IO:

  • File、FileInfo
  • Directory、DirectoryInfo
  • Stream 与各种派生流

File 与 FileInfo

File 更适合一次性静态操作,FileInfo 更适合围绕单个文件对象进行多次操作。

string path = @"C://test.txt";
if (File.Exists(path))
{
    File.Delete(path);
}

File.WriteAllText(path, "just for test");

流与读写器

流本质上是一段字节序列的抽象。常见流包括:

  • FileStream
  • MemoryStream
  • NetworkStream
  • GZipStream

使用 FileStream 可以直接读写字节,也可以配合 StreamReader、StreamWriter 操作文本。

using (FileStream fs = File.Open(path, FileMode.OpenOrCreate))
{
    string msg = "test";
    byte[] msgArray = Encoding.UTF8.GetBytes(msg);
    fs.Write(msgArray, 0, msgArray.Length);
}

异步文件操作

对于大文件写入或 IO 密集场景,可以使用异步文件流。

using (FileStream fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, 4096, true))
{
    byte[] buffer = Encoding.UTF8.GetBytes("Hello");
    await fs.WriteAsync(buffer, 0, buffer.Length);
}

相比旧式的 BeginWrite / EndWrite,现代 C# 中直接使用 WriteAsync 会更自然。

归纳总结

从 C# 学习笔记(1) 到 (19),这一阶段的重点其实可以概括成四件事:

  1. 建立整体认知:理解 C# 与 .NET Framework 的关系,以及代码真正如何被执行。
  2. 打好语言基础:掌握标识符、关键字、基础数据类型、变量、常量与作用域。
  3. 理解类型系统与抽象能力:类、结构体、值类型、引用类型、泛型、接口、委托、事件、扩展方法与 LINQ。
  4. 具备工程实践意识:线程、异步、文件 IO,以及对性能、封装和可维护性的基本判断。

如果把这些内容串起来看,就会发现它们并不是彼此孤立的知识点,而是 C# 从语言设计到工程开发的一整条主线。前面学到的委托,会在事件和异步里继续出现;前面理解的泛型,会在集合、LINQ 和框架 API 里反复用到;而对值类型、引用类型、线程与 IO 的认识,则会直接影响代码正确性和性能表现。

一句话收尾:这一整组内容学完,写 C# 时就不再只是“会写”,而是开始知道“为什么这么写”。