前言

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

C# 与 .NET Framework 起步

什么是 C#

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

语言基础

标识符与关键字

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

标识符

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

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

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

关键字

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

数据类型

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

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

数值类型

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

  • 整型:intlongshortbytechar
  • 浮点型:floatdouble
  • 十进制型:decimal

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

布尔类型

bool 用于表示逻辑值 truefalse

字符串类型

stringSystem.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;
        }
    }
}

如果字段不希望被随意修改,可以用 readonlyconst

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。因此,每个对象默认都具备 EqualsGetHashCodeGetTypeToString 等基础能力。

接口

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

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# 类型大体可以分为两类:

  • 值类型:如 intboolenumstruct
  • 引用类型:如 classinterfacedelegatestring、数组

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

装箱与拆箱

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

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

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

参数传递

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

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

此外,还可以使用 refout 实现按引用传递。

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;

与传统 foreachif 相比,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 的本质

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

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

  • FileFileInfo
  • DirectoryDirectoryInfo
  • Stream 与各种派生流

FileFileInfo

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 可以直接读写字节,也可以配合 StreamReaderStreamWriter 操作文本。

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# 时就不再只是“会写”,而是开始知道“为什么这么写”。