CodeGym /课程 /C# SELF /纯函数和不可变性

纯函数和不可变性

C# SELF
第 51 级 , 课程 1
可用

1. 介绍

纯函数——是指在相同输入下总是返回相同结果且不产生任何副作用的函数。简单说:纯函数不“搞乱”周围的东西,也不被外界污染。

函数被认为是纯的,如果:

  • 基于输入参数计算值。
  • 不修改外部变量或程序状态。
  • 不依赖于可能变化的外部变量或状态。

纯性的两个黄金规则

  1. 确定性:相同输入——相同输出。
  2. 无副作用:函数不改变自身外部的任何东西:既不改文件,也不改全局变量,也不改用户界面(嗨,Console.WriteLine)。

不是宗教,而是常识

看上去这可能像学术上的玩意儿,但实践会证明它很实用:

  • 纯函数是可预测的。它容易测试——给定参数就能得到确定结果。
  • 纯函数可以随意在代码里重排,不用担心某处会被搞坏。
  • 在多核环境下,纯函数提供安全保证:可以并行运行而不用担心数据竞争。

2. 纯函数和非纯函数的示例

纯函数:一切可预测

// 纯函数:不碰外部任何东西
int Add(int a, int b)
{
    return a + b;
}

int Square(int x)
{
    return x * x;
}

多次调用 Add(2, 3)——每次都会是 5。虽然无聊,但可靠。

非纯函数:违反规则

// 违反纯性:依赖外部状态(静态/全局变量)
int counter = 0;

int Increase()
{
    counter++;
    return counter;
}

这里调用 Increase() 每次返回不同的值——不再是确定性的。

// 违反纯性:产生外部副作用(在屏幕上打印)
int AddAndPrint(int a, int b)
{
    int sum = a + b;
    Console.WriteLine(sum); // 副作用!
    return sum;
}

那随机性和时间怎么办?

任何使用 DateTime.NowRandom 的函数都不是纯的:

// 非纯!
int GetRandomNumber()
{
    return new Random().Next();
}

差异表

特征 纯函数 非纯函数
对相同参数总是相同结果
副作用 没有
依赖外部状态 没有

3. 数据不可变性:理论与实践

不可变性 (immutability)——是一种在创建后对象不能被修改的做法。需要新值时就创建新对象。

为什么重要?

  • 应用更不容易因为意外的数据修改而崩溃。
  • 没有偷偷修改的“泄露”:如果你有一个对象,别人不能在背后改它的字段。
  • 不可变性是许多自动优化和并行计算的基础。

C# 中的简单例子

.NET 中的不可变类型

在 C# 中字符串(string)是不可变的!每次你做 string.Concat(s, "world"),都会创建一个新字符串。

string s = "Hello";
string t = s;
s = s + " World";
Console.WriteLine(t); // t == "Hello"

数组和集合:默认是可变的

int[] numbers = { 1, 2, 3 };
numbers[0] = 42; // 数组被修改了!

用通俗的方式理解不可变性:编译代码的思路

我们不要修改已有对象/值,而是返回新的:

// 比如不要这样做:
void AddToList(List<int> list, int value)
{
    list.Add(value); // 会变异!
}

// 更好这样:
List<int> AddToList(List<int> list, int value)
{
    var newList = new List<int>(list) { value }; // 新列表
    return newList;
}

示意:改或不改

flowchart LR
    A[原始对象] --"变异"--> B[同一对象,但内部不同]
    A --"不可变性"--> C[新对象]

4. 在真实的 C# 代码里为什么需要这点?

  • 在现代 C# 中,像 LINQ、Entity Framework 和 ASP.NET Core 那样的库倾向于使用纯函数和不可变性。
  • 不可变性减少了那些“魔法” bug,当有人在某处把重要值覆盖掉时就不会莫名其妙出错。
  • 纯函数使得自动化测试(unit tests)更简单,因为测试只需关心输入和输出,不用操心外部世界。

例子:字符串处理

string s = "Hello";
string newS = s.Replace("H", "J"); // s 仍然是 "Hello"; newS 是 "Jello"

例子:LINQ 和集合

Where, Select 等方法返回新的集合,不会改动旧的。

var numbers = new List<int> { 1, 2, 3, 4 };
var evenNumbers = numbers.Where(n => n % 2 == 0).ToList();

// numbers 保持不变!

真实场景例子:通过不可变对象做配置

很多现代 .NET API 使用不可变对象来做配置,比如 JsonSerializerOptions

var options = new JsonSerializerOptions
{
    WriteIndented = true
};
// 这个对象不会在“演出”过程中被随意改变,提升了可靠性。

5. 常见错误和陷阱

问题通常出现在你在看似“纯”的代码里不小心去变异数据时。

集合经常出事:你想过滤列表,但过程中改了原始列表。

或者忘了像 List<T>.Add 这样的 API 会就地修改对象。

狡猾的例子:

List<int> DoubleTheNumbers(List<int> xs)
{
    // 错误!我们变异了原始列表,返回了同一个对象。
    foreach (var i in xs)
        xs.Add(i * 2);
    return xs;
}

这段代码甚至会抛出运行时错误(InvalidOperationException),因为我们在遍历时修改集合——这是变异导致的经典问题。

正确做法:

List<int> DoubleTheNumbers(List<int> xs)
{
    var newList = new List<int>(xs.Select(x => x * 2));
    return newList;
}
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION