摘要本文是 C# 进阶学习笔记梳理了这几天学习的泛型、集合、异常处理、IO 流与多线程五大知识模块。文章从「类型复用与安全」「数据组织与存储」「错误处理」「数据持久化」「并发执行」五个方向出发结合老师布置的作业案例和实际踩过的坑帮助初学者建立知识之间的联系而非简单罗列 API。全文包含大量可运行的代码示例和易错点提醒适合正在学习 C# 基础和进阶语法的同学参考。一、前言这几天在学什么C# 学习内容从基础语法转向了更接近实际开发的知识。简单说这几天围绕几个核心问题展开如何让一段代码适用于不同类型泛型如何组织和存储数据集合程序出错时怎么办异常处理如何把数据保存到磁盘IO以及如何让程序同时做多件事多线程。下面按这个逻辑逐个整理每个部分都会结合老师布置的作业案例把知识点落到实处。二、泛型写完一次适用多种类型2.1 为什么需要泛型写 C# 代码时经常遇到这样的场景一个方法或类只因为参数类型不同就要写好几份几乎一样的代码。用object虽然能暂时解决问题但会带来两个麻烦一是需要频繁装箱拆箱影响性能二是类型不安全运行时才报错。泛型的核心作用就是让「类型」变成可以传递的参数编译时就能确定类型既保证类型安全又省去重复代码。举个例子如果用ArrayList非泛型集合存数据存进去都是object取出来必须强制转换。一旦类型不匹配编译器不会报错运行时直接抛InvalidCastException——这类 bug 排查起来很费时间。泛型集合ListT在编译阶段就锁定了类型根本不会出现这种问题。2.2 泛型类与泛型方法泛型类在定义时使用类型参数T实例化时再指定具体类型。泛型方法同理可以在方法级别定义独立的类型参数。除此之外还有泛型接口和泛型委托思想一致把类型的选择权推迟到使用时。下面是一个简单的泛型容器示例// 定义一个简单的泛型容器 class MyBoxT { public T Value { get; set; } public void Show() { Console.WriteLine($值{Value}); } } // 使用 MyBoxint intBox new MyBoxint { Value 100 }; intBox.Show(); // 输出值100 MyBoxstring strBox new MyBoxstring { Value Hello }; strBox.Show(); // 输出值Hello同一个MyBoxT定义传int就存整数传string就存字符串不需要为每种类型重复写类。T就是一个占位符具体类型在实例化时填入。2.3 泛型约束有时类型参数不能是任意类型比如要求T必须是引用类型或必须有无参构造函数。这时用泛型约束where T : class——约束为引用类型where T : struct——约束为值类型where T : new()——要求有无参构造函数where T : 基类名——约束为指定基类或其子类where T : 接口名——约束为实现了指定接口的类型合理使用约束能让泛型代码更安全、更可控。比如一个泛型方法需要在内部创建实例就必须加new()约束否则编译不通过。三、异常处理程序出错了怎么应对3.1 异常的本质程序运行时难免遇到意料之外的情况访问空对象NullReferenceException、数组越界IndexOutOfRangeException、除数为零DivideByZeroException、参数格式不对FormatException。这些问题发生时.NET 会抛出一个异常对象Exception如果代码里不做任何处理程序直接崩溃。异常处理的目的不是让错误不发生而是让代码在出错时有一个体面的反应。3.2 try-catch-finally 的执行逻辑基本结构是try-catch-finallytry块包裹可能出错的代码catch块捕获并处理特定类型的异常finally块中的代码无论是否抛出异常都会执行适合放释放文件句柄、关闭数据库连接等清理逻辑。多重catch时要注意顺序具体异常写前面Exception放最后否则编译器会报错。下面是一个安全除法的例子try { Console.Write(输入被除数); int a int.Parse(Console.ReadLine()); Console.Write(输入除数); int b int.Parse(Console.ReadLine()); Console.WriteLine($结果{a / b}); } catch (DivideByZeroException) { Console.WriteLine(除数不能为零。); } catch (FormatException) { Console.WriteLine(请输入有效的整数。); } catch (Exception ex) { Console.WriteLine($发生未知错误{ex.Message}); } finally { Console.WriteLine(计算结束。); }finally块里的「计算结束」无论成功与否都会打印这一点在文件操作中特别有用——确保文件句柄在出错时也能被释放。3.3 主动抛出异常除了被动捕获也可以用throw主动抛出异常。比如在方法参数校验不通过时抛出ArgumentException让调用方明确感知到问题所在。作业里常见的场景是参数为 null 或超出合法范围时主动throw new ArgumentException(参数不合法)而不是让程序带着错误数据继续跑。四、集合各种数据容器的选择4.1 数组与 ArrayList 的对比数组长度固定定义后不能动态增删元素适合事先知道数据量的场景。用Length获取长度。下面是一个典型作业示例输入 5 个数字计算总和和平均值int[] nums new int[5]; int sum 0; for (int i 0; i nums.Length; i) { Console.Write($请输入第{i 1}个数字); nums[i] int.Parse(Console.ReadLine()); sum nums[i]; } Console.WriteLine($总和{sum}平均值{sum / 5.0:F1});ArrayList可以动态扩容容量随元素增减自动调整。但它本质上保存的是object可以存放不同类型取出来时需要显式类型转换否则编译通过但运行时报错。下面用ArrayList重写上面的求和逻辑并加入字符串操作和查找删除// 用 ArrayList 保存数字并计算总和 ArrayList list new ArrayList(); int sum 0; for (int i 0; i 5; i) { Console.Write($请输入第{i 1}个数字); int num int.Parse(Console.ReadLine()); list.Add(num); sum num; } Console.WriteLine($总和{sum}平均值{sum / 5.0:F1}); // 用 ArrayList 保存字符串按索引读取并循环遍历 ArrayList names new ArrayList() { 张三, 李四, 王五 }; for (int i 0; i names.Count; i) { // 取出元素时需要类型转换 string name (string)names[i]; Console.WriteLine($第{i}个姓名{name}); } // 根据姓名查找索引找到后删除 string target 李四; int index names.IndexOf(target); // 查找对象在集合中的索引 if (index ! -1) { names.RemoveAt(index); // 按索引删除 Console.WriteLine(${target} 已从集合中删除。); } else { Console.WriteLine($未找到 {target}); }这段代码涉及ArrayList的多个常用操作Add添加、Insert插入到指定位置、Remove删除对象、RemoveAt按索引删除、Clear清空、Count获取元素个数、IndexOf查找索引。实际开发中新项目应优先使用泛型集合ListT原因有两个一是类型安全不需要手动类型转换二是避免了值类型的装箱拆箱开销。4.2 QueueT 与 StackTQueueT队列是先进先出FIFO像排队Enqueue入队Dequeue出队。StackT栈是后进先出LIFO像一摞盘子Push入栈Pop出栈。Peek只查看顶部/队首元素但不移除。队列适合任务排队、消息处理栈适合撤销CtrlZ、浏览器的前进后退、括号匹配等场景。常用操作速查操作QueueTFIFOStackTLIFO说明添加元素Enqueue(item)Push(item)将元素放入集合取出并移除Dequeue()Pop()取出后元素不再存在只查看不移除Peek()Peek()查看但不改变集合元素个数CountCount获取集合中的元素数量4.3 DictionaryTKey, TValue 与 SortedListDictionaryTKey, TValue是键值对结构通过唯一 Key 快速查找 Value查找速度接近 O(1)。SortedListTKey, TValue在键值对基础上保持按键自动排序适合需要有序访问的场景但插入性能略低于 Dictionary。常用方法速查Add(key, value)新增键值对Key 重复会抛异常dict[key] value索引器可用于新增或修改ContainsKey(key)判断键是否存在TryGetValue(key, out value)安全读取Remove(key)删除键值对foreach (var kv in dict)遍历KeyValuePairTKey, TValue。下面用一个统计单词次数的示例串起字典的基本操作string text apple banana apple orange banana apple; string[] words text.Split( ); Dictionarystring, int dict new Dictionarystring, int(); foreach (string w in words) { if (dict.ContainsKey(w)) dict[w]; // 键已存在次数加一 else dict[w] 1; // 键不存在新增条目 } foreach (var kv in dict) Console.WriteLine(${kv.Key} 出现 {kv.Value} 次);这里ContainsKey先判断再操作避免了 Key 重复时Add抛异常的问题。4.4 作业案例字典的几个典型场景老师在作业中设计了几个非常实用的字典应用场景这里挑典型来说场景一网站收藏夹。网站名称作为 KeyURL 作为 Value查找后可以用Process.Start(url)打开网页Dictionarystring, string favorites new Dictionarystring, string(); favorites[百度] https://www.baidu.com; favorites[GitHub] https://github.com; // 安全查找并打开 if (favorites.TryGetValue(GitHub, out string url)) { Console.WriteLine($即将打开{url}); // System.Diagnostics.Process.Start(url); // 实际打开网页 } else { Console.WriteLine(收藏夹中没有该网站。); }场景二城市与邮编。城市已存在时修改邮编否则新增城市。这种「存在则更新、不存在则新增」的逻辑用索引器最方便Dictionarystring, string cities new Dictionarystring, string(); string city 北京; string zipCode 100000; // 索引器存在则覆盖不存在则新增 cities[city] zipCode; Console.WriteLine(${city} 的邮编已更新为 {zipCode});场景三学生管理。用Dictionarystring, Student保存学号和学生对象。学生类内部做属性校验比如姓名长度限制、分数范围 0-100外部通过学号快速查找学生并计算平均分class Student { private string name; private int score; public string Name { get { return name; } set { if (string.IsNullOrWhiteSpace(value) || value.Length 10) throw new ArgumentException(姓名长度应在1-10之间。); name value; } } public int Score { get { return score; } set { if (value 0 || value 100) throw new ArgumentException(分数应在0-100之间。); score value; } } } // 使用 Dictionarystring, Student students new Dictionarystring, Student(); students[S001] new Student { Name 张三, Score 85 }; students[S002] new Student { Name 李四, Score 92 }; double avg students.Values.Average(s s.Score); Console.WriteLine($平均分{avg:F1});场景四手机号状态管理。用Dictionarystring, bool表示号码是否可用true 可用false 已占用。选择成功后更新状态为 false这种场景下字典比 List 更直观Dictionarystring, bool phones new Dictionarystring, bool() { {13800000001, true}, {13800000002, true}, {13800000003, false} // 已占用 }; string selected 13800000001; if (phones.ContainsKey(selected) phones[selected]) { phones[selected] false; Console.WriteLine(${selected} 选择成功。); } else { Console.WriteLine(号码不可用。); }场景五模拟数据表。用ListDictionarystring, object模拟一张汽车数据表每行是一个 Dictionary列名是 Key值用 object 存储不同类型ListDictionarystring, object cars new ListDictionarystring, object(); cars.Add(new Dictionarystring, object() { {品牌, 比亚迪}, {型号, 汉EV}, {价格, 219800} }); cars.Add(new Dictionarystring, object() { {品牌, 特斯拉}, {型号, Model 3}, {价格, 259900} }); // 遍历输出 foreach (var car in cars) Console.WriteLine(${car[品牌]} {car[型号]}价格{car[价格]}元);4.5 易错点小结集合这边我在作业中踩过的坑整理如下索引从 0 开始「第四个元素」对应索引 3不是 4。Remove删除对象RemoveAt删除索引两者不要混淆。如果ArrayList存的是整数Remove(3)是删除值为 3 的元素不是删除索引为 3 的元素。数组用Length集合用Count不要交叉使用。ArrayList 取出元素后需要类型转换泛型集合ListT不需要。Dictionary 的 Key 不能重复重复使用Add会抛异常。先ContainsKey判断或直接使用索引器修改。判断存在后再取值用TryGetValue比直接索引器更安全避免 Key 不存在时抛KeyNotFoundException。Dictionary拼写不能写成Directory后者是文件夹操作类完全是两码事。使用Dictionarystring, Student时要注意对象内部属性的封装和校验不要让外部直接修改内部数据绕过校验逻辑。五、文件与 IO把数据存到磁盘上5.1 路径、文件、目录三个概念的区分操作文件前先分清三个概念路径Path描述文件或目录的位置是一个字符串Path类只处理路径字符串本身不会直接创建物理文件文件File是存储数据的实体目录Directory是组织文件的容器。常用类速查Path类GetFileName获取文件名、GetExtension获取扩展名、GetDirectoryName获取目录名、GetFullPath获取完整路径、Combine安全拼接路径比手动拼反斜杠更可靠。File类静态方法适合一次性操作如Create、Copy、Move、Delete、ReadAllText、WriteAllText、Exists。Directory类静态方法适合文件夹的CreateDirectory、Delete、GetFiles、GetDirectories。FileInfo/DirectoryInfo面向实例的版本适合需要多次操作同一文件或目录的场景可以获取文件大小等属性。有一个典型的作业题是根据输入路径复制文件到 D 盘文件不存在时输出提示Console.Write(请输入源文件路径); string sourcePath Console.ReadLine(); string destPath Path.Combine(D:\, Path.GetFileName(sourcePath)); if (File.Exists(sourcePath)) { File.Copy(sourcePath, destPath, overwrite: true); Console.WriteLine($文件已复制到 {destPath}); } else { Console.WriteLine(源文件不存在请检查路径。); }这里用了两个重要的习惯复制前先File.Exists判断用Path.Combine拼接路径而不是手动拼反斜杠避免漏斜杠或重复斜杠的问题。5.2 字节流与字符流从底层到封装Stream 是一段连续的数据流带有一个当前位置指针。从底层到高层IO 流有几个层次FileStream按字节读写适合处理所有文件类型图片、视频、文本都可以需要配合FileMode打开方式、FileAccess访问权限、FileShare共享方式使用。BufferedStream给流加一层缓冲区减少直接磁盘读写次数提升性能。MemoryStream操作内存中的流数据存在内存而非磁盘。StreamReader/StreamWriter在字节流基础上封装了字符编码处理适合读写文本文件。结合using语句可以自动释放流资源。下面是一个用FileStream读取文件并逐行输出的例子string path D:\data\scores.txt; if (File.Exists(path)) { using (FileStream fs new FileStream(path, FileMode.Open, FileAccess.Read)) using (StreamReader sr new StreamReader(fs, Encoding.UTF8)) { string line; while ((line sr.ReadLine()) ! null) Console.WriteLine(line); } } else { Console.WriteLine(文件不存在。); }这里using块保证流在读写完成后被自动DisposeEncoding.UTF8则确保中文等字符不会乱码。5.3 作业案例目录遍历与文件复制作业中涉及不少目录操作这里汇总两个典型场景遍历文件夹的子目录和文件string dirPath D:\test; if (Directory.Exists(dirPath)) { // 遍历子目录 foreach (string dir in Directory.GetDirectories(dirPath)) Console.WriteLine($目录{dir}); // 遍历文件 foreach (string file in Directory.GetFiles(dirPath)) Console.WriteLine($文件{Path.GetFileName(file)}); // 只遍历 txt 文件 foreach (string txtFile in Directory.GetFiles(dirPath, *.txt)) Console.WriteLine($TXT文件{Path.GetFileName(txtFile)}); }将文件夹中的文件复制到另一个目录只复制目标目录中不存在的文件string sourceDir D:\source; string targetDir D:\backup; // 目标目录不存在则先创建 if (!Directory.Exists(targetDir)) Directory.CreateDirectory(targetDir); foreach (string file in Directory.GetFiles(sourceDir)) { string destFile Path.Combine(targetDir, Path.GetFileName(file)); if (!File.Exists(destFile)) // 目标不存在才复制 { File.Copy(file, destFile); Console.WriteLine($已复制{Path.GetFileName(file)}); } }这里的关键习惯创建目录前判断Directory.Exists复制前判断目标文件是否已存在目标目录不存在时File.Copy会失败必须先创建目录。5.4 FileStream 循环读写的正确姿势用FileStream.Read读取文件时有一个重要细节一次不一定读完整个文件必须用循环读取直到返回 0 表示读完。写入时也要用实际读取长度count不能盲目写整个 buffer否则可能把无效数据写入文件string sourcePath D:\data\source.jpg; string destPath D:\data\copy.jpg; using (FileStream fsRead new FileStream(sourcePath, FileMode.Open, FileAccess.Read)) using (FileStream fsWrite new FileStream(destPath, FileMode.Create, FileAccess.Write)) { byte[] buffer new byte[4096]; int count; // 循环读取直到返回 0 while ((count fsRead.Read(buffer, 0, buffer.Length)) 0) { fsWrite.Write(buffer, 0, count); // 使用实际读取长度 count不是 buffer.Length } } Console.WriteLine(文件复制完成。);5.5 易错点小结复制前先判断File.Exists创建目录前先判断Directory.Exists。Path.Combine比手动拼反斜杠更安全自动处理路径分隔符。FileStream.Read要循环读取一次不一定读完整个文件直到返回 0 才结束。写入时使用实际读取长度count不要写整个 buffer否则尾部可能混入无效数据。文件复制目标目录不存在时File.Copy会失败必须先创建目录。处理中文文本时读取和写入编码必须一致优先使用 UTF-8。用using或try-finally确保流被正确释放。六、多线程让程序同时做多件事6.1 进程与线程进程是资源分配的单位线程是执行单位。一个进程至少包含一个线程主线程也可以包含多个工作线程。多线程让程序在「同一时间」处理多个任务比如 UI 界面在响应用户点击的同时后台还能下载文件。实际开发中多线程适合独立计算、IO 等待、模拟多个用户访问等场景。6.2 Thread 的基本操作与作业案例使用System.Threading.Thread类创建线程调用Start()才会真正启动线程直接调用方法只是普通同步调用。Sleep让当前线程暂停指定毫秒数Join等待指定线程执行完毕IsAlive判断线程是否仍在运行。带参数的线程可以使用ParameterizedThreadStart委托。线程优先级ThreadPriority只影响调度概率不保证绝对执行顺序。先看一个简单示例感受多线程的执行节奏Thread t new Thread(() { for (int i 1; i 3; i) { Console.WriteLine($线程工作中第{i}次); Thread.Sleep(500); } }); t.Start(); t.Join(); // 等待线程结束确保主线程在此阻塞 Console.WriteLine(线程工作完成。);没有Join()的话主线程可能在子线程结束前就执行完毕输出顺序会变得不可控。作业中典型的多线程场景有这几个场景一两个线程分别计算 1 到 100 的和——每个线程独立计算不涉及共享数据线程安全不是问题int sum1 0, sum2 0; Thread t1 new Thread(() { for (int i 1; i 50; i) sum1 i; }); Thread t2 new Thread(() { for (int i 51; i 100; i) sum2 i; }); t1.Start(); t2.Start(); t1.Join(); t2.Join(); // 等两个线程都结束 Console.WriteLine($总和{sum1 sum2});场景二将数组拆分两个线程分别排序最后合并。这个题目综合了排序算法和多线程协作int[] arr { 9, 3, 7, 1, 5, 8, 2, 6, 4, 0 }; int mid arr.Length / 2; int[] left new int[mid]; int[] right new int[arr.Length - mid]; Array.Copy(arr, 0, left, 0, mid); Array.Copy(arr, mid, right, 0, arr.Length - mid); Thread t1 new Thread(() Array.Sort(left)); Thread t2 new Thread(() Array.Sort(right)); t1.Start(); t2.Start(); t1.Join(); t2.Join(); // 必须等两个排序线程都结束再合并 // 双指针合并两个有序数组 int[] result new int[arr.Length]; int i 0, j 0, k 0; while (i left.Length j right.Length) result[k] left[i] right[j] ? left[i] : right[j]; while (i left.Length) result[k] left[i]; while (j right.Length) result[k] right[j]; Console.WriteLine($排序结果{string.Join(, , result)});这里有一个易错点合并前必须等两个排序线程结束Join否则合并的是未排序的数组。6.3 共享数据与 lock多个线程同时读写同一个变量时可能出现竞态问题一个线程改到一半另一个线程也来读或改最终结果不可预测。解决办法是用lock保护临界区保证同一时刻只有一个线程进入被锁住的代码块。lock的参数通常是一个专用的private static readonly object对象不要用this或字符串字面量。另外线程开得越多不一定越快线程创建、上下文切换和锁竞争都会产生开销当任务本身是计算密集型时盲目增加线程反而可能拖慢程序。还要注意Random共享实例在多线程中可能产生竞争实际项目应考虑使用线程安全的随机数方案。七、综合案例控制台版文件处理与数据统计下面用一个完整的综合案例把本周的知识串联起来。这个案例模拟一个实际场景从成绩文件中读取学生数据用面向对象的方式组织数据进行统计计算并演示多线程并行处理using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading; class Student { public string Name { get; set; } public int Score { get; set; } } class Program { static void Main() { string filePath D:\data\scores.txt; ListStudent students new ListStudent(); Dictionarystring, int gradeCount new Dictionarystring, int(); // 1. 用 IO 读取文件用异常处理应对文件缺失和格式错误 try { using (StreamReader sr new StreamReader(filePath, Encoding.UTF8)) { string line; while ((line sr.ReadLine()) ! null) { string[] parts line.Split(,); if (parts.Length ! 2) continue; string name parts[0].Trim(); // TryParse 比 Parse 更健壮格式错误不会抛异常 if (!int.TryParse(parts[1].Trim(), out int score)) continue; students.Add(new Student { Name name, Score score }); } } } catch (FileNotFoundException) { Console.WriteLine(未找到成绩文件请检查路径。); return; } finally { // finally 块确保资源相关的提示总是输出 Console.WriteLine(文件读取阶段结束。); } if (students.Count 0) { Console.WriteLine(没有读取到任何有效数据。); return; } // 2. 用泛型方法计算统计值 double avgScore CalcAverage(students); Student topStudent FindMax(students); Console.WriteLine($平均分{avgScore:F1}最高分{topStudent.Name} - {topStudent.Score}); // 3. 用 Dictionary 统计各等级人数 foreach (var s in students) { string grade s.Score 90 ? 优秀 : s.Score 80 ? 良好 : s.Score 60 ? 及格 : 不及格; if (!gradeCount.ContainsKey(grade)) gradeCount[grade] 0; gradeCount[grade]; } Console.WriteLine(--- 等级分布 ---); foreach (var kv in gradeCount) Console.WriteLine(${kv.Key}{kv.Value} 人); // 4. 数据量大时用多线程并行处理演示双线程合并思路 int mid students.Count / 2; double avgLeft 0, avgRight 0; Thread t1 new Thread(() avgLeft CalcAverage(students.GetRange(0, mid))); Thread t2 new Thread(() avgRight CalcAverage(students.GetRange(mid, students.Count - mid))); t1.Start(); t2.Start(); t1.Join(); t2.Join(); Console.WriteLine($双线程分别计算平均分——前半{avgLeft:F1}后半{avgRight:F1}); } // 泛型方法计算平均分 static double CalcAverage(ListStudent list) { if (list.Count 0) return 0; double sum 0; foreach (var s in list) sum s.Score; return sum / list.Count; } // 泛型方法查找最高分学生 static Student FindMax(ListStudent list) { Student max list[0]; foreach (var s in list) if (s.Score max.Score) max s; return max; } }知识串联说明泛型——ListStudent存储学生列表泛型方法CalcAverage和FindMax复用统计逻辑集合——Dictionarystring, int统计等级人数ListStudent配合GetRange拆分数据异常处理——try-catch-finally处理文件缺失和格式错误TryParse替代Parse增强健壮性IO——StreamReaderusingEncoding.UTF8按行读取文件多线程——双线程并行计算两部分平均分Join等待结束后输出结果。八、总结本周学习的四条主线经过这些内容的学习和作业的练习如果只记住一堆 API 名称其实意义不大。真正值得记住的是四条主线复用——泛型让代码写一次适用多种类型稳定——异常处理让程序遇到错误时不会直接崩溃存储——集合负责组织内存中的数据IO 负责把数据持久化到磁盘并发——多线程让多个任务同时推进。后续写项目时遇到问题不妨回到这四条线上去找对应的知识点。参考知识点C# 泛型编程类型参数与约束集合类对比Array / ArrayList / ListT / Dictionary / Queue / Stack异常处理模型try-catch-finally 与 throw文件与流操作File / Directory / FileStream / StreamReader / StreamWriter多线程基础Thread / Sleep / Join / lock