在本节中,我们将系统性地探讨方法(函数)在程序结构中的核心作用。方法不仅是组织程序行为的基本单元,更承载着清晰、模块化和可维护的逻辑流程。这节课内容覆盖方法的定义与调用、参数传递(含 ref/out/in 修饰符)、返回值(包括元组)、
局部函数、表达式体方法、递归技术,以及高阶函数与 Lambda 表达式。

我们先写一个最小而完整的示例,逐行理解每个关键点。
|// basics.cs using System; class MethodBasics { // 声明一个方法:有两个 int 参数,返回它们的和(int) static int Add(int a, int b) { // 方法体:完成具体计算 int sum = a + b; // 计算 return sum; // 返回值 }
让我们逐行深入理解这个方法声明:
方法签名分解: static int Add(int a, int b)
static 关键字: 表示这个方法属于类本身,而不属于类的某个具体实例。想象一下,我们还没有创建任何 MethodBasics 对象,就能直接通过类名调用 MethodBasics.Add(3, 5)。这就像数学中的加法运算——它是一个通用的概念,不需要依附于某个特定的"计算器对象"。int 返回类型: 告诉编译器和其他程序员,这个方法执行完毕后会返回一个整数值。如果我们写 int result = Add(3, 5);,编译器就知道 result 变量会接收到一个 int 类型的值。Add 方法名: 这是我们给这个动作起的名字。好的方法名应该清楚地表达它的用途——看到 Add 我们就知道这是在做加法运算,而不需要查看方法内部的实现。(int a, int b): 这里定义了方法的"输入端口"。a 和 b 叫做形式参数(形参),它们就像是方法内部的占位符变量。当我们调用 Add(3, 5) 时,数字 3 会赋值给 a,数字 5 会赋值给 b。void有的动作只是“做事”,不需要返回值,比如打印日志。这种方法返回类型写 void:
|static void Log(string message) { Console.WriteLine($"[LOG] {message}"); }
调用时直接写方法名即可:Log("程序启动");。
参数就像是方法的"入口通道",它们决定了外界可以传递什么样的数据给我们的方法。想象一下,参数就像是一扇门上的不同插槽——有些插槽必须插入特定的钥匙(必选参数), 有些插槽有默认的钥匙可以不插(可选参数),还有些插槽可以同时插入多把钥匙(可变参数)。
默认情况下,调用要按顺序传参:
|static string FormatName(string givenName, string familyName) { return $"{familyName} {givenName}"; } // 位置参数调用 string full = FormatName("小明", "张"); // 命名参数:更清晰,避免顺序混淆 string full2 = FormatName(givenName:
命名参数尤其适合当多个同类型参数并置时,能显著提升可读性。
可选参数在声明时给出默认值,调用方可以省略:
|static string Greet(string name, string punctuation = "!") { return $"你好,{name}{punctuation}"; } // 既可写 Greet("小王"), 也可写 Greet("小王", "!!!")
注意:可选参数必须在必选参数之后。
params当我们希望接收“若干个同类型的参数”时,可用 params:
|static int Sum(params int[] numbers) { int total = 0; foreach (int n in numbers) total += n; return total; } // 支持 Sum(1), Sum(1,2,3), 也支持 Sum(new[] {1,2,3})
ref / out / in默认是“值传递”:方法拿到实参的一个副本,不会影响外部变量。ref/out/in 提供了“引用传递”的能力:
|// ref:传入前必须先赋值;方法内可以读写;调用端变量会被更新 static void Increment(ref int x) { x = x + 1; } // out:传入前不需要赋值;方法必须在内部赋值;用于“带回多个结果”的场景 static bool TryDivide(int a, int b, out int quotient) { if (b == 0) {
建议:初学阶段优先使用“返回值/元组返回/类封装结果”的方式表达输出;out 用于与 .NET 生态一致的 TryXxx 模式;ref/in 更多见于性能敏感场景。
当我们想从一个方法返回多个值,最直观的方式是返回元组:
|// 同时返回最小值、最大值、平均值 static (int min, int max, double avg) Analyze(int[] numbers) { if (numbers == null || numbers.Length == 0) { return (0, 0, 0); // 早返回,避免继续计算 } int min
元组返回让代码“就地表达”,避免为一次性结果定义专门类型。需要长期传递或跨层传递的结果,仍建议定义类型提升语义。
当方法很短时,可以用表达式体写法让代码更紧凑:
|static int Square(int x) => x * x;
局部函数是“定义在方法内部”的小方法,能很好地封装只在当前方法使用的逻辑:
|static string NormalizeName(string raw) { // 局部函数:只在本方法内使用 static string TrimAndLower(string s) => s.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(raw)) return string.Empty; string normalized = TrimAndLower(raw); // 首字母大写 return char
局部函数默认是私有到当前方法的,不会污染类的命名空间;读取外部局部变量也更直观。
变量有作用域(哪里能看见它)和生命周期(它活多久)。这两个概念是理解程序执行的基础。
作用域(Scope) 决定了变量名在代码的哪些地方是可见的、可访问的。在 C# 中,花括号 {} 会创建一个新的块作用域,就像在房间里再隔出一个小房间——外面的东西里面能看到,但里面的东西外面看不到。
生命周期(Lifetime) 指的是变量在内存中存在的时间段。一般来说,当程序执行流程离开变量的作用域时,该变量就会被标记为可回收,等待垃圾回收器清理。
让我们通过一个更简单的例子来理解:
|int x = 10; { int y = 20; // y 只在这个块里可见 Console.WriteLine(x + y); } // Console.WriteLine(y); // 错误:y 不在作用域内
方法内的局部变量在方法返回后就会被回收(由垃圾回收器管理托管内存)。
递归就是一个方法在自己的内部调用自己,就像照镜子时镜子里又有镜子的感觉。不过,为了不让这个"自我调用"无限下去(那样程序就死循环了),我们需要两个关键要素:
第一个要素:基线条件(停止条件) 就是告诉递归"什么时候该停下来"。比如计算阶乘时,当数字小于等于1时就停止递归,直接返回1。
第二个要素:向基线逼近
每次递归调用时,都要让问题变得更小一点,这样最终能够达到停止条件。比如计算 n! 时,我们计算 n * (n-1)!,把原问题变成了一个更小的问题。
我们用阶乘来看看递归是怎么工作的:
|static long Factorial(int n) { if (n < 0) throw new ArgumentException("n 不能为负"); if (n <= 1) return 1; // 基线条件 return n * Factorial(n - 1); // 向基线逼近 }
再看斐波那契数列的例子:
|// 低效版本:指数级重复计算,仅用作演示 // 很明显,这个方法的性能很差,因为它会重复计算很多次一些相同的值 static long FibSlow(int n) { if (n < 0) throw new ArgumentException("n 不能为负"); if (n <= 1) return n; return FibSlow(n - 1) + FibSlow(n - 2); }
关于尾递归:C# 编译器不保证尾调用优化。对于深度较大的递归,优先改写为迭代,或者使用显式堆栈结构。
一个好的方法(函数)应该清晰表达“前置条件(Preconditions)”与“后置承诺(Postconditions)”。前置条件不满足时,尽早失败(抛出异常)更易于定位问题。
|static int IndexOf(string text, char ch) { if (text is null) throw new ArgumentNullException(nameof(text)); for (int i = 0; i < text.Length; i++) { if (text[i] == ch) return i; }
与抛异常并行的一种风格是 TryXxx 模式:不抛错,用布尔值表示成功与否:
|static bool TryParseScore(string? s, out int score) { if (!int.TryParse(s, out score)) return false; if (score < 0 || score > 100) return false; return true; }
纯函数是函数式编程中的一个重要概念,它有着明确的特征和巨大的价值。让我们深入理解什么是纯函数,以及为什么我们要追求纯函数。
纯函数的三个特征:
确定性: 相同的输入永远产生相同的输出。就像数学中的函数 f(x) = x + 1,无论何时计算 f(3),结果都是 4。这个特性让我们的代码变得可预测、可信赖。
无外部依赖: 不依赖任何外部的可变状态,比如全局变量、系统时间、随机数生成器等。函数所需的所有信息都通过参数传入,这样我们就能确保函数的行为完全由输入决定。
无副作用: 不产生任何外部可观察的副作用,比如修改全局变量、执行 I/O 操作(文件读写、网络请求、数据库操作)、在控制台打印信息等。函数只是"算出一个结果",不会"改变世界"。
下面是一个纯函数的例子:
|// 纯函数:根据原价与折扣计算应付金额 static decimal CalculatePayable(decimal price, decimal discountRate, decimal taxRate) { if (price < 0 || discountRate < 0 || taxRate < 0) throw new ArgumentException("输入不能为负"); decimal discounted = price * (1
现在我们来聊聊 C# 中一个非常有意思的概念——委托(delegate)。你可以把委托想象成"函数的类型",就像 int 是整数的类型一样,委托是函数的类型。
什么意思呢?想象一下,我们平时写代码时,可以把整数存在变量里,比如 int number = 42;。委托让我们也能把函数"存起来",传来传去,甚至作为参数传递给其他函数。这就是高阶函数的基础。
在实际开发中,我们很少直接定义委托类型,而是使用 .NET 提供的三个非常好用的泛型委托:
Action: 代表"没有返回值的函数"。比如打印日志、发送邮件这类"做事情但不返回结果"的操作。
Func<T1, T2, ..., TResult>: 代表"有返回值的函数"。比如计算两个数的和、格式化字符串等"输入一些东西,输出一个结果"的操作。最后一个类型参数 TResult 是返回值类型,前面的都是输入参数类型。
Predicate<T>: 代表"判断函数",接收一个 T 类型的参数,返回 bool。比如"判断一个数是否为偶数"、"判断一个字符串是否包含特定内容"等。
这三个委托类型覆盖了我们日常开发中绝大部分的函数传递需求。
下面是一个使用委托的例子:
|// 使用 Func 传入计算策略 static int Compute(int a, int b, Func<int, int, int> op) { return op(a, b); } static void DemoOps() { int sum = Compute(3, 5, (x
Lambda 表达式有一个非常强大的特性:它可以"捕获"外部作用域的变量,这种机制在编程语言中叫做"闭包"(Closure)。简单来说,就是 Lambda 函数不仅可以使用自己的参数,还能"记住"并使用定义它时所在环境中的变量。
这个特性让我们能写出非常灵活的代码,但同时也需要注意一些潜在的陷阱:
变量的生命周期问题: 当 Lambda 捕获了局部变量,这个变量的生命周期可能会被意外延长。比如我们把包含捕获变量的 Lambda 传递到其他地方保存起来,那么被捕获的变量也会一直存在于内存中,直到 Lambda 被垃圾回收。
并发环境下的修改风险: 如果多个线程同时访问或修改被捕获的变量,就可能出现竞态条件(race condition)。特别是当我们在循环中创建多个 Lambda,而这些 Lambda 都捕获了同一个循环变量时,很容易出现意想不到的结果。
值捕获 vs 引用捕获: C# 中,值类型变量被捕获时是按值捕获的(会复制一份),而引用类型变量是按引用捕获的(指向同一个对象)。这个区别在某些场景下会影响程序的行为。
|static Func<int, int> MakeAdder(int delta) { // 返回一个函数:输入 x,输出 x + delta return x => x + delta; // 捕获了 delta } static void DemoAdder() { var add10 = MakeAdder(10); Console.WriteLine(add10(5
4. 词频统计练习
编写一个函数 CountWords(string text),实现以下功能:
Dictionary<string, int>,键是单词,值是出现次数ToLower() 或 ToLowerInvariant())char.IsLetter() 或正则表达式提取单词,去除标点符号Split() 方法),然后过滤掉空字符串和只包含标点的部分Main 方法中调用该函数,获取词频字典OrderByDescending() 按频次降序排序,然后使用 Take(10) 取前10个提示:可以使用 string.Split() 配合 char.IsPunctuation() 或正则表达式来提取单词。
|using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; class WordFrequency { static Dictionary<string, int> CountWords(string text) { if
5. 日程合并练习
编写一个程序,实现以下功能:
(TimeSpan start, TimeSpan end) 元组或自定义类来表示时间段MergeIntervals(List<(TimeSpan, TimeSpan)> intervals) 合并重叠的时间段[09:00-10:30] 表示从9:00到10:30FindFreeSlots() 计算空闲时段:假设工作时间为 09:00-18:00,找出所有不在忙碌时段的时间段Main 方法中测试:输入两个人的忙碌时段,合并后输出空闲时段提示:可以先对时间段按开始时间排序,然后遍历合并重叠区间。
|using System; using System.Collections.Generic; using System.Linq; class ScheduleMerger { // 合并重叠的时间段 static List<(TimeSpan start, TimeSpan end)> MergeIntervals( List<(TimeSpan start, TimeSpan end)> intervals)
6. 表达式计算器练习
编写一个表达式计算器,支持加减乘除和括号,使用"中缀转后缀 + 栈"算法实现求值。
要求:
InfixToPostfix(string infix) 函数:将中缀表达式转换为后缀表达式(逆波兰表达式)EvaluatePostfix(string postfix) 函数:计算后缀表达式的值( < + - < * / < )Stack<T> 数据结构辅助转换和计算Main 方法中测试:输入中缀表达式(如 "(3+4)*5-6"),输出计算结果提示:
|using System; using System.Collections.Generic; using System.Linq; class ExpressionCalculator { // 获取运算符优先级 static int GetPrecedence(char op) { return op switch { '+' or '-' => 1, '*'
|前10个高频词: the: 2 dog: 2 quick: 1 brown: 1 fox: 1 jumps: 1 over: 1 lazy: 2 was: 1 not: 1
说明:
Regex.Matches() 使用正则表达式 \b[a-zA-Z]+\b 匹配单词边界内的字母序列ToLowerInvariant() 转换为小写,忽略区域设置OrderByDescending() 按值降序排序Take(10) 取前10个元素Dictionary 统计词频,键存在则递增,不存在则初始化为1|合并后的忙碌时段: 09:00 - 11:00 13:00 - 15:30 空闲时段: 11:00 - 13:00 15:30 - 18:00
说明:
TimeSpan.Parse() 解析时间字符串为 TimeSpan 对象{slot.start:hh\\:mm} 格式化输出时间,\\ 转义冒号|中缀表达式: (3+4)*5-6 后缀表达式: 34+5*6- 计算结果: 29
说明:
( 入栈,遇到 ) 弹出到 ( 为止* / 高于 + -Stack<T> 实现栈数据结构