C# 入门
2 / 11
面向对象
自在学
首页课程创意工坊价格
首页课程创意工坊价格
编程C# 函数与逻辑

函数与逻辑

在本节中,我们将系统性地探讨方法(函数)在程序结构中的核心作用。方法不仅是组织程序行为的基本单元,更承载着清晰、模块化和可维护的逻辑流程。这节课内容覆盖方法的定义与调用、参数传递(含 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 void Main() { // 调用方法时,传入实参(arguments) int result = Add(3, 5); Console.WriteLine($"3 + 5 = {result}"); } }

让我们逐行深入理解这个方法声明:

方法签名分解: 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: "小明", familyName: "张");

命名参数尤其适合当多个同类型参数并置时,能显著提升可读性。


可选参数与默认值

可选参数在声明时给出默认值,调用方可以省略:

|
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) { quotient = 0; return false; } quotient = a / b; return true; } // in:只读引用传递(适合大型结构体避免拷贝) static int Length(in ReadOnlySpan<char> span) { return span.Length; } static void Demo() { int n = 10; Increment(ref n); // n -> 11 if (TryDivide(10, 2, out int q)) { Console.WriteLine($"商: {q}"); } }

建议:初学阶段优先使用“返回值/元组返回/类封装结果”的方式表达输出;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 = numbers[0]; int max = numbers[0]; long sum = 0; foreach (int n in numbers) { if (n < min) min = n; if (n > max) max = n; sum += n; } double avg = (double)sum / numbers.Length; return (min, max, avg); } static void Main() { var result = Analyze(new[] { 3, 7, 2, 9 }); Console.WriteLine($"min={result.min}, max={result.max}, avg={result.avg:F2}"); }

元组返回让代码“就地表达”,避免为一次性结果定义专门类型。需要长期传递或跨层传递的结果,仍建议定义类型提升语义。


表达式体方法与局部函数

当方法很短时,可以用表达式体写法让代码更紧凑:

|
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.ToUpper(normalized[0]) + normalized.Substring(1); }

局部函数默认是私有到当前方法的,不会污染类的命名空间;读取外部局部变量也更直观。


作用域与生命周期

变量有作用域(哪里能看见它)和生命周期(它活多久)。这两个概念是理解程序执行的基础。

作用域(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); } // 高效版本:迭代 + 滚动状态 static long FibFast(int n) { if (n < 0) throw new ArgumentException("n 不能为负"); long a = 0, b = 1; for (int i = 0; i < n; i++) { long next = a + b; a = b; b = next; } return a; }

关于尾递归: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; } return -1; // 没找到 }

与抛异常并行的一种风格是 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; }

纯函数与副作用

纯函数是函数式编程中的一个重要概念,它有着明确的特征和巨大的价值。让我们深入理解什么是纯函数,以及为什么我们要追求纯函数。

纯函数的三个特征:

  1. 确定性: 相同的输入永远产生相同的输出。就像数学中的函数 f(x) = x + 1,无论何时计算 f(3),结果都是 4。这个特性让我们的代码变得可预测、可信赖。

  2. 无外部依赖: 不依赖任何外部的可变状态,比如全局变量、系统时间、随机数生成器等。函数所需的所有信息都通过参数传入,这样我们就能确保函数的行为完全由输入决定。

  3. 无副作用: 不产生任何外部可观察的副作用,比如修改全局变量、执行 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 - discountRate); decimal taxed = discounted * (1 + taxRate); return decimal.Round(taxed, 2); }

高阶函数与 Lambd

现在我们来聊聊 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, y) => x + y); // Lambda:匿名函数 int max = Compute(3, 5, (x, y) => Math.Max(x, y)); Console.WriteLine($"sum={sum}, max={max}"); } // 使用 Predicate 过滤 static int[] Filter(int[] source, Predicate<int> predicate) { List<int> result = new List<int>(); foreach (int n in source) { if (predicate(n)) result.Add(n); } return result.ToArray(); } static void DemoFilter() { int[] data = { 1, 2, 3, 4, 5, 6 }; int[] evens = Filter(data, n => n % 2 == 0); Console.WriteLine(string.Join(",", evens)); // 2,4,6 }

Lambda 表达式的变量捕获(闭包)

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)); // 15 Console.WriteLine(add10(20)); // 30 }

小练习

  1. C#方法的返回类型可以是?
  1. C#中用于引用传递的参数修饰符包括?
  1. .NET中常用的泛型委托类型包括?

4. 词频统计练习

编写一个函数 CountWords(string text),实现以下功能:

  • 函数返回 Dictionary<string, int>,键是单词,值是出现次数
  • 忽略大小写:将所有单词转换为小写后统计(使用 ToLower() 或 ToLowerInvariant())
  • 忽略标点符号:使用 char.IsLetter() 或正则表达式提取单词,去除标点符号
  • 可以按空格分割字符串(使用 Split() 方法),然后过滤掉空字符串和只包含标点的部分
  • 在 Main 方法中调用该函数,获取词频字典
  • 使用 LINQ 的 OrderByDescending() 按频次降序排序,然后使用 Take(10) 取前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 (string.IsNullOrWhiteSpace(text)) return new Dictionary<string, int>(); // 使用正则表达式提取单词(字母序列) var words = Regex.Matches(text, @"\b[a-zA-Z]+\b") .Cast<Match>() .Select(m => m.Value.ToLowerInvariant()); var dict = new Dictionary<string, int>(); foreach (var word in words) { if (dict.ContainsKey(word)) dict[word]++; else dict[word] = 1; } return dict; } static void Main() { string text = "The quick brown fox jumps over the lazy dog. The dog was not lazy."; var wordCounts = CountWords(text); var top10 = wordCounts .OrderByDescending(kvp => kvp.Value) .Take(10); Console.WriteLine("前10个高频词:"); foreach (var kvp in top10) { Console.WriteLine($"{kvp.Key}: {kvp.Value}"); } } }
|
前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

5. 日程合并练习

编写一个程序,实现以下功能:

  • 定义时间段结构:可以使用 (TimeSpan start, TimeSpan end) 元组或自定义类来表示时间段
  • 编写函数 MergeIntervals(List<(TimeSpan, TimeSpan)> intervals) 合并重叠的时间段
  • 输入格式:两个人的忙碌时段,如 [09:00-10:30] 表示从9:00到10:30
  • 合并规则:如果两个时间段有重叠(一个的结束时间 >= 另一个的开始时间),则合并为一个时间段
  • 编写函数 FindFreeSlots() 计算空闲时段:假设工作时间为 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) { if (intervals == null || intervals.Count == 0) return new List<(TimeSpan, TimeSpan)>(); // 按开始时间排序 var sorted = intervals.OrderBy(i => i.start).ToList(); var merged = new List<(TimeSpan, TimeSpan)> { sorted[0] }; for (int i = 1; i < sorted.Count; i++) { var current = sorted[i]; var last = merged[merged.Count - 1]; // 如果当前区间与最后一个合并区间重叠,则合并 if (current.start <= last.end) { merged[merged.Count - 1] = ( last.start, current.end > last.end ? current.end : last.end ); } else { merged.Add(current); } } return merged; } // 找出空闲时段 static List<(TimeSpan start, TimeSpan end)> FindFreeSlots( List<(TimeSpan start, TimeSpan end)> busySlots, TimeSpan workStart, TimeSpan workEnd) { var freeSlots = new List<(TimeSpan, TimeSpan)>(); var merged = MergeIntervals(busySlots); TimeSpan current = workStart; foreach (var busy in merged) { if (current < busy.start) { freeSlots.Add((current, busy.start)); } current = busy.end > current ? busy.end : current; } if (current < workEnd) { freeSlots.Add((current, workEnd)); } return freeSlots; } static void Main() { // 示例:两个人的忙碌时段 var person1 = new List<(TimeSpan, TimeSpan)> { (TimeSpan.Parse("09:00"), TimeSpan.Parse("10:30")), (TimeSpan.Parse("14:00"), TimeSpan.Parse("15:30")) }; var person2 = new List<(TimeSpan, TimeSpan)> { (TimeSpan.Parse("10:00"), TimeSpan.Parse("11:00")), (TimeSpan.Parse("13:00"), TimeSpan.Parse("14:30")) }; // 合并两个人的忙碌时段 var allBusy = new List<(TimeSpan, TimeSpan)>(); allBusy.AddRange(person1); allBusy.AddRange(person2); var merged = MergeIntervals(allBusy); Console.WriteLine("合并后的忙碌时段:"); foreach (var slot in merged) { Console.WriteLine($"{slot.start:hh\\:mm} - {slot.end:hh\\:mm}"); } // 找出空闲时段(工作时间 09:00-18:00) var freeSlots = FindFreeSlots( allBusy, TimeSpan.Parse("09:00"), TimeSpan.Parse("18:00") ); Console.WriteLine("\n空闲时段:"); foreach (var slot in freeSlots) { Console.WriteLine($"{slot.start:hh\\:mm} - {slot.end:hh\\:mm}"); } } }
|
合并后的忙碌时段: 09:00 - 11:00 13:00 - 15:30 空闲时段: 11:00 - 13:00 15:30 - 18:00

说明:

  • TimeSpan.Parse() 解析时间字符串为 TimeSpan 对象
  • 合并重叠区间:先排序,然后遍历,如果当前区间与上一个重叠则合并
  • 空闲时段计算:在工作时间范围内,找出所有不在忙碌时段的时间段
  • {slot.start:hh\\:mm} 格式化输出时间,\\ 转义冒号

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, '*' or '/' => 2, _ => 0 }; } // 中缀转后缀 static string InfixToPostfix(string infix) { var output = new System.Text.StringBuilder(); var stack = new Stack<char>(); foreach (char c in infix) { if (char.IsDigit(c)) { output.Append(c); } else if (c == '(') { stack.Push(c); } else if (c == ')') { while (stack.Count > 0 && stack.Peek() != '(') { output.Append(stack.Pop()); } stack.Pop(); // 弹出 '(' } else if (c == '+' || c == '-' || c == '*' || c == '/') { while (stack.Count > 0 && stack.Peek() != '(' && GetPrecedence(stack.Peek()) >= GetPrecedence(c)) { output.Append(stack.Pop()); } stack.Push(c); } } while (stack.Count > 0) { output.Append(stack.Pop()); } return output.ToString(); } // 计算后缀表达式 static double EvaluatePostfix(string postfix) { var stack = new Stack<double>(); foreach (char c in postfix) { if (char.IsDigit(c)) { stack.Push(double.Parse(c.ToString())); } else { double b = stack.Pop(); double a = stack.Pop(); double result = c switch { '+' => a + b, '-' => a - b, '*' => a * b, '/' => a / b, _ => throw new ArgumentException($"未知运算符: {c}") }; stack.Push(result); } } return stack.Pop(); } static void Main() { string infix = "(3+4)*5-6"; Console.WriteLine($"中缀表达式: {infix}"); string postfix = InfixToPostfix(infix); Console.WriteLine($"后缀表达式: {postfix}"); double result = EvaluatePostfix(postfix); Console.WriteLine($"计算结果: {result}"); } }
|
中缀表达式: (3+4)*5-6 后缀表达式: 34+5*6- 计算结果: 29

说明:

  • 中缀转后缀:数字直接输出,运算符根据优先级入栈或出栈
  • 遇到 ( 入栈,遇到 ) 弹出到 ( 为止
  • 运算符优先级:* / 高于 + -
  • 后缀求值:数字入栈,运算符弹出两个数计算后入栈
  • 使用 Stack<T> 实现栈数据结构
  • 注意:这个简化版本只支持单位数字,实际应用中需要处理多位数
  • 方法的基本概念
    • 返回值与 `void`
  • 参数
    • 位置参数与命名参数
    • 可选参数与默认值
    • 可变参数 `params`
    • 参数修饰符:`ref` / `out` / `in`
  • 返回值的更多方式
  • 表达式体方法与局部函数
  • 作用域与生命周期
  • 递归
  • 异常
  • 纯函数与副作用
  • 高阶函数与 Lambd
    • Lambda 表达式的变量捕获(闭包)
  • 小练习

目录

  • 方法的基本概念
    • 返回值与 `void`
  • 参数
    • 位置参数与命名参数
    • 可选参数与默认值
    • 可变参数 `params`
    • 参数修饰符:`ref` / `out` / `in`
  • 返回值的更多方式
  • 表达式体方法与局部函数
  • 作用域与生命周期
  • 递归
  • 异常
  • 纯函数与副作用
  • 高阶函数与 Lambd
    • Lambda 表达式的变量捕获(闭包)
  • 小练习
自在学

© 2025 自在学,保留所有权利。

公网安备湘公网安备43020302000292号 | 湘ICP备2025148919号-1

关于我们隐私政策使用条款

© 2025 自在学,保留所有权利。

公网安备湘公网安备43020302000292号湘ICP备2025148919号-1