这个窗体程序的题目乍一看像是课后练习:随机生成 1 到 100 之间的 10 个数字,再通过委托实现升序或降序排列。真正动手写会发现,它几乎把 C# 桌面开发里最容易出岔子的几个点全串了起来——随机数种子的取法、单选按钮事件的触发次数、委托指向方法的时机、排序时数据被悄悄当成字符串比较。我见过不少人第一次跑出来的结果不正常,出错的地方还各不一样:有人说“随机数每次都是同一批”,有人说“点了降序没反应”,还有人的列表排出来是 1、10、100、2、20 这种鬼东西。
这篇就把这个小工具从头拆一遍。适合已经摸过 C# 语法、想搞清楚委托到底在什么位置起作用的人,也适合正在做 WinForms 入门项目、不想只抄一份能跑就行的代码的人。我会把界面搭建、随机数生成、委托声明与挂接、两种排序实现、以及调试时踩过的坑都摊开讲,代码可以直接照搬,参数和边界条件也会说明白为什么这么取。
1. 需求解构与方案选型:从一句话需求到可执行的行为
1.1 把题目翻译成界面上的具体动作
“随机生成 1-100 之间的 10 个数字”这句话里藏着三个约束,单独看都不难,凑在一起就得想清楚先后顺序。第一是数量固定为 10 个,意味着数组或列表的容量在生成那一刻就确定了,后面排序只是换顺序,不改元素个数;第二是取值范围 1 到 100,这是闭区间,写代码时上界要写成 101,因为主流随机数 API 的上界是开区间,这个差一错误是新手阶段最高频的 bug 之一;第三是随机,也就是同一时刻连点两次“生成”,应该得到两批不同的数,如果两次结果一模一样,基本就是种子被重复初始化了。
再往后是“通过委托实现升序或降序排列”,这句话把需求拆成了两半:排序算法本身和比较规则。排序算法(冒泡、插入、内置快排都行)只关心“谁前谁后”,而谁前谁后是由比较规则决定的。委托的作用就是把比较规则从算法里抽出来,做成一个可以随时替换的变量。算法代码写一遍就够了,升序还是降序,取决于这个变量当前指向哪个方法。
所以在界面上,我需要这些东西:一个按钮负责生成数据,一个区域显示原始数据,一组单选按钮(或一个下拉框)选择排序方向,一个按钮触发排序,另一个区域显示排序结果。数据流是单向的:生成 → 展示 → 排序 → 再展示。中途不需要保存文件,也不需要持久化,全部活在内存里。这个判断很重要,因为它决定了我不需要引入任何数据层代码,一个私有数组字段就够,窗体关掉数据自然消失。
1.2 排序方向为什么值得单独抽成委托
不用委托也能实现,最简单粗暴的写法是在排序方法里加一个布尔参数,if (ascending) { 交换 } else { 反向交换 }。这种写法在只有两种排序方向时完全能跑,但它有两个隐患:一是排序方法内部出现了分支,逻辑变复杂了,冒泡排序的双层循环里再嵌一个 if,读起来很累;二是扩展性差,将来如果要求“按个位数排序”“按数字和排序”,这个布尔参数就没法表达了,只能继续加参数或者再加分支,方法签名会越来越臃肿。
委托方案把“比较两个整数谁大谁小”这件事定义成一个独立的方法,排序算法只负责调用它,不关心它内部怎么比。想换规则,换一个方法挂上去就行,排序算法的代码一行都不用改。这就是策略模式最朴素的形态。下面这张表是我在实际项目里选型时会对照的几种做法,成本差异很明显:
| 实现方式 | 代码形态 | 新增一种排序规则的成本 | 适用场景 |
|---|---|---|---|
| 布尔参数 + if 分支 | 一个排序方法,内部判断 | 修改方法签名和内部逻辑 | 一次性作业,规则确定不再变 |
| 枚举 + switch | 传入枚举,switch 分发 | 加 case 分支 | 规则少且集中在同一处 |
| 自定义委托变量 | 一个排序方法 + 一个委托字段 | 新增一个比较方法并挂接 | 本题首选,教学与中小工具都合适 |
IComparer<int>接口 | 每种规则一个类 | 新增一个类,可复用可注入 | 规则需要跨项目复用 |
Comparison<int>内置委托 | 一行 lambda | 无需改动任何方法 | 生产代码里最省事的写法 |
我倾向于在入门阶段先用自定义委托,原因是它把“委托是一个类型、一个变量、可以指向不同方法”这件事暴露得最彻底。等这个观念立住了,再换成Func<int,int,int>或Comparison<int>,你会发现只是换了个声明方式,本质完全一样。上来就用内置泛型委托,很多人写了半年也不知道自己其实一直在用委托。
2. 界面搭建:控件命名、布局与事件绑定的边界
2.1 控件清单与布局思路
窗体不用做得多花哨,但要保证信息分区清楚:上面是操作区,中间是数据显示区。我习惯把“生成”和“排序”放在同一行,单选按钮夹在中间,这样视线从上到下就是“生成 → 选方向 → 排序”的自然顺序。数据显示用两个ListBox比用TextBox拼字符串更好,因为每一项独占一行,一眼能看出哪一个位置的数字变了,验证排序结果时特别方便。
| 控件类型 | Name | 用途 | 关键属性设置 |
|---|---|---|---|
Button | btnGenerate | 生成 10 个随机数 | Text = "生成随机数" |
Button | btnSort | 按当前方向排序 | Text = "排序" |
RadioButton | rdoAsc | 升序 | Text = "升序",Checked = true |
RadioButton | rdoDesc | 降序 | Text = "降序" |
ListBox | lstSource | 显示原始数据 | IntegralHeight = false,Height固定 |
ListBox | lstSorted | 显示排序结果 | 同上 |
Label | lblSource/lblSorted | 两个列表的说明文字 | Text = "原始数据" / "排序结果" |
命名这块我坚持一个原则:控件名带类型前缀。btn、rdo、lst、lbl,看着土,但当窗体上有二十个控件、事件处理方法有十几个时,你搜索lstSource能一秒钟定位,搜索listBox2就只能一个个点过去。另一个原则是两个单选按钮的Checked属性只在设计器里给rdoAsc置true,rdoDesc保持false,别两个都设成true,那样运行时行为取决于控件创建顺序,非常不稳定。
2.2 Designer.cs 的边界:哪些能改,哪些碰都别碰
WinForms 项目里一个窗体其实是两个文件:Form1.Designer.cs存放设计器自动生成的布局代码,Form1.cs存放你的业务逻辑。很多初学者图省事,直接打开Designer.cs手写控件创建代码,结果在可视化设计器里拖动一下控件,代码被重新生成,手写的东西全部蒸发。这个坑我踩过,后来定了死规矩:InitializeComponent()方法体里一个字都不手动加,需要动态创建控件就在构造函数里InitializeComponent()之后写。
还有一个更隐蔽的顺序问题。InitializeComponent()里事件的订阅语句和你写的初始化代码,谁先谁后决定了事件处理方法的执行时机。比如rdoAsc.Checked = true这行如果放在设计器里(也就是InitializeComponent内部,早于你的构造函数代码),那么rdoAsc_CheckedChanged会在构造阶段就被触发一次,而此时你的委托字段_comparer可能还是null,事件方法里如果直接调用它,程序还没显示窗口就崩了。这就是典型的“构造函数里抛异常,界面都看不到”的诡异现象。
解决办法有两个,我两个都用:一是在事件方法体里先判断if (rdoAsc.Checked),避免因为另一个按钮取消选中而误触发;二是所有初始化赋值放在InitializeComponent()之后,并且委托字段的默认值在字段声明处就给上,不依赖事件触发的先后。这两条加起来,就能把“窗体还没出来就报错”这一类问题挡在门外。
2.3 随机数生成的三个坑,一个比一个隐蔽
坑一:Random对象创建在循环里。这是教科书级别的经典错误。new Random()的无参构造会用系统时钟做种子,循环执行太快,十次new拿到的种子很可能完全相同,生成出来的数字就整整齐齐一模一样。正确做法是Random对象只创建一次,放在字段或循环外面,然后反复调用它的Next方法。
坑二:框架版本带来的 API 差异。如果你用的是 .NET 6 及以上,可以直接用Random.Shared,它是一个线程安全的静态实例,省掉了自己维护字段的麻烦。但如果是 .NET Framework 4.x 的老项目(很多学校的教学环境就是这样),Random.Shared根本不存在,编译直接报错。稳妥的写法是自己声明一个静态只读字段:
// .NET Framework 4.x 与 .NET 6+ 都能用的写法 private static readonly Random Rnd = new Random();坑三:范围边界写错。Next(minValue, maxValue)的maxValue是开区间,要生成 1 到 100 的整数(含 100),必须写Rnd.Next(1, 101)。写成Next(1, 100)的话,100 永远不会出现,测试时如果只跑几次,你根本发现不了。我习惯在写完这句后立刻在旁边加一行注释// [1, 100],提醒自己上界加过一。
把这三条合起来,生成逻辑就十行不到:
private int[] _numbers = new int[10]; private void btnGenerate_Click(object sender, EventArgs e) { for (int i = 0; i < _numbers.Length; i++) { _numbers[i] = Rnd.Next(1, 101); // [1, 100] 闭区间 } ShowNumbers(lstSource, _numbers); lstSorted.Items.Clear(); // 新数据来了,旧结果作废 }生成新数据时顺手清空排序结果列表,这个小动作很重要。不清的话,左边已经是新的 10 个数,右边还挂着上一轮的排序结果,用户很容易误以为排序没生效。
3. 委托:从声明、挂接到切换的完整链路
3.1 自定义委托类型的声明与签名约定
委托的声明本质上是在定义一种“方法的形状”。我们要让排序算法调用它来比较两个整数,那么这位“比较员”的输入是两个int,输出是一个int,用返回值的正负表示大小关系。声明就一行:
// 约定:返回值 > 0 表示 x 应排在 y 后面;< 0 表示 x 应排在前面;= 0 表示相等 private delegate int CompareHandler(int x, int y);这个返回值语义必须写进注释,否则半年后回头看代码,根本想不起来return x.CompareTo(y)到底代表升序还是降序。这个约定和 .NET 里IComparer<T>.Compare的约定是一致的,所以一旦习惯了,可以无缝迁移到其他 API。
有了类型,就能声明字段了:
private CompareHandler _comparer;此时_comparer的值是null,它还没有指向任何方法。委托变量和普通引用变量一样,未赋值时调用会抛NullReferenceException,所以要么在字段声明处给个默认值,要么在调用前做空判断。我倾向于两步都做,双保险。
3.2 两个比较方法的写法与“为什么降序要反过来”
升序和降序的实现各一行,但很多人写降序时会卡住,不知道该怎么反。想清楚一件事就通了:委托的返回值表达的是“第一个参数相对第二个参数的位置关系”。升序时,x比y小就该排前面,所以x.CompareTo(y)返回负数,正好符合;降序时,x比y小应该排后面,那就让返回值变成正数,把参数顺序调过来写y.CompareTo(x)就实现了符号翻转。
private static int Ascending(int x, int y) => x.CompareTo(y); private static int Descending(int x, int y) => y.CompareTo(x);不调CompareTo、手写return x - y;也能跑,而且看起来更短。但这里有个真实存在的风险:当两个数差值接近int的边界时,减法会溢出。虽然本题的数字都在 1 到 100 之间,溢出是不可能的,但养成用CompareTo的习惯没坏处,将来换成long、DateTime、string时,减法写法直接失效,CompareTo依然管用。这是那种“现在多打几个字、将来少改一次代码”的选择。
3.3 委托变量的赋值、切换与空引用防御
委托的赋值就是让变量指向方法,语法上直接写方法名即可(不需要括号,加了括号就变成调用并取返回值了,编译会报类型不匹配):
_comparer = Ascending; // 指向升序 _comparer = Descending; // 改指向降序这里有个新手容易混淆的细节:_comparer = Ascending和_comparer = new CompareHandler(Ascending)是等价的,后者是早期写法,现在编译器会自动转换,写简写就行。真正的关键是赋值时机——必须保证在调用排序之前完成。我把它放在单选按钮的CheckedChanged事件里,用户点哪个方向,委托就指向哪个方法。
private void rdoAsc_CheckedChanged(object sender, EventArgs e) { if (rdoAsc.Checked) // 只在被选中时赋值 { _comparer = Ascending; } } private void rdoDesc_CheckedChanged(object sender, EventArgs e) { if (rdoDesc.Checked) { _comparer = Descending; } }为什么一定要加if (xxx.Checked)?因为两个单选按钮同组时,选中 A 会让 B 自动变成未选中,B 的CheckedChanged也会被触发一次。如果事件方法里不加判断,就会连续赋值两次,最终_comparer指向谁,取决于事件触发的先后顺序。虽然本例中第二次赋值指向的恰好是用户真正选中的那个方向(因为触发顺序通常是先取消旧的、再选中新的),但这是巧合,不是保证。加了判断,逻辑就变成确定的。
还有一个必须警惕的东西:多播委托。如果把赋值写成_comparer += Ascending,委托就变成了多播,内部维护一个调用列表。此时调用_comparer(1, 2),它会依次执行列表里所有方法,但返回值只取最后一个方法的。也就是说,+=写了两次升序一次降序之后,比较结果完全由最后挂上的降序决定,前面两次调用白做。这个 bug 极其难查,因为程序不报错,只是排序方向莫名其妙。所以,切换策略用=,不要用+=。
3.4 沿着委托再往前一步:为什么事件也是委托
写到这里可以顺便把认知打通。WinForms 里给按钮绑定点击事件,双击按钮生成的btnSort_Click(object sender, EventArgs e),本质就是委托。Button.Click是一个EventHandler类型的委托字段(内部包装成事件),+=订阅,-=取消订阅,和上面_comparer = Ascending是同一套机制,只是多了访问控制。理解了这一点,很多事情就通了:为什么事件方法可以手动调用、为什么重复订阅会导致方法执行多次、为什么取消订阅必须传入同一个方法引用。
顺带一个实战经验:窗体上如果有循环里动态创建的按钮,每个按钮都+=同一个处理方法,一定要在创建时先-=再+=,否则如果这段代码被执行两次,每个按钮会绑定两份处理方法,点一下触发两次,表现出来就是“生成两批数据但只看到一批”。这个坑我在做动态表格时踩过一次,排查了半天。
4. 排序实现:手写冒泡与内置排序两条路
4.1 手写冒泡排序,把比较环节交给委托
为什么还要手写冒泡?因为这道题的价值就在委托的回调时机上。用Array.Sort一行搞定,委托在哪一步被调用、调用了几次,你完全看不见。手写一遍,你会非常清楚地感受到:排序算法自己不判断大小,它只是把相邻两个元素丢给委托,根据返回值决定要不要交换。
// 冒泡排序:升序还是降序,完全由 compare 决定 private static void BubbleSort(int[] data, CompareHandler compare) { for (int i = 0; i < data.Length - 1; i++) { bool swapped = false; for (int j = 0; j < data.Length - 1 - i; j++) { if (compare(data[j], data[j + 1]) > 0) // 只关心符号 { int tmp = data[j]; data[j] = data[j + 1]; data[j + 1] = tmp; swapped = true; } } if (!swapped) break; // 一趟下来没交换,说明已经有序 } }内层循环的上界是data.Length - 1 - i,减i是因为每一轮结束,最大的(或最小的)那个已经沉到末尾了,不需要再比。这个优化在只有 10 个元素时看不出差别,但它是冒泡排序的标准写法,写惯了自然就成肌肉记忆。swapped标志是提前退出优化,对于本来就接近有序的数据能省掉大量无效比较。这两处加起来,就是“写代码的人有没有认真想过算法边界”的区别。
方法的第二个参数类型是CompareHandler,说明它接受一个委托。注意参数名我写的是compare,不是_comparer,因为方法内部不该依赖外部字段,把委托当参数传进来,这个方法就变成纯粹的、可测试的工具方法,跟窗体完全解耦。这种写法在单元测试时特别有用,脱离界面临时构造几个委托就能验证逻辑。
4.2 用 Array.Sort 和 Comparison 一行替换
实际项目里当然不该手写冒泡,内置排序在数据量大时快得多。既然委托的语义和Comparison<int>完全一致,直接套用即可:
int[] copy = (int[])_numbers.Clone(); // 复制一份,保留原始数据 Array.Sort(copy, (a, b) => _comparer(a, b));Array.Sort的第二个参数是Comparison<T>委托,传一个 lambda 进去,lambda 内部再转调我们的_comparer,就完成了从自定义委托到内置委托的桥接。当然也可以写得更直接:Array.Sort(copy, (a, b) => a.CompareTo(b)),那是升序;(a, b) => b.CompareTo(a)是降序。用 lambda 的缺点是排序逻辑散落在调用处,不像委托变量那样可以集中管理、随时切换。
这里必须强调Clone()这一步。如果直接对_numbers排序,原始数组就被改掉了,lstSource里显示的还是排好序的结果,看起来像“升序和降序没区别”。我第一次写的时候就是这么干的,盯着屏幕纳闷了半天:为什么点了降序,两个列表长得一模一样?原因是ListBox在排序前就已经用旧数据填充完了,视觉效果和底层数据脱节,而_numbers本身被原地改了,下一次再切换方向时,排序的起点已经是上一轮的结果,最终两个方向排出来的东西自然一致。
4.3 结果呈现:字符串拼接的坑和数值排序的陷阱
显示部分很简单,遍历数组往ListBox里塞:
private static void ShowNumbers(ListBox box, int[] data) { box.BeginUpdate(); // 暂停重绘,减少闪烁 box.Items.Clear(); foreach (int n in data) { box.Items.Add(n); } box.EndUpdate(); }BeginUpdate/EndUpdate这一对在数据量大的时候效果明显,本题只有 10 条无所谓,但养成习惯没坏处,将来列表变成几千条时你会感谢自己。
真正值得单独拎出来说的是排序对象搞错。这个坑极普遍:把数字先转成字符串再排序,或者图省事用OrderBy(item => item.ToString()),结果得到 1、10、100、2、20 这种顺序。原因是字符串比较是逐字符比较的,第一个字符'1'比'2'小,所以"10"就排到了"2"前面。在 1 到 100 这个范围内,只要出现一位数和两位数混排,问题立刻暴露。
| 排序对象 | 代码写法 | 1~100 范围内结果 |
|---|---|---|
| 整数(正确) | x.CompareTo(y) | 1, 2, 3, ..., 10, ..., 100 |
| 字符串(错误) | x.ToString().CompareTo(y.ToString()) | 1, 10, 100, 11, 12, ..., 2, 20 |
判断方法很简单:看冒泡里的交换条件比较的是什么类型。只要比较的是string,就一定有问题。如果确实需要用字符串展示,正确顺序是先按数值排好,再ToString()显示,顺序不能颠倒。同理,如果列表里出现负数,用"0"补齐位数做字符串排序勉强能对,但那是在绕远路,不如老老实实比数值。
5. 完整实现与运行验证
5.1 窗体代码骨架与文件分工
把前面的碎片拼起来,一个完整的Form1.cs大约长这样。设计器文件里的布局代码省略,只保留逻辑部分:
using System; using System.Windows.Forms; namespace RandomSortDemo { public partial class Form1 : Form { // 1. 委托类型定义 private delegate int CompareHandler(int x, int y); // 2. 状态字段 private static readonly Random Rnd = new Random(); private readonly int[] _numbers = new int[10]; private CompareHandler _comparer; public Form1() { InitializeComponent(); rdoAsc.Checked = true; // 默认升序 _comparer = Ascending; // 字段显式赋初值,不依赖事件 } // 3. 比较方法 private static int Ascending(int x, int y) => x.CompareTo(y); private static int Descending(int x, int y) => y.CompareTo(x); // 4. 生成 private void btnGenerate_Click(object sender, EventArgs e) { for (int i = 0; i < _numbers.Length; i++) { _numbers[i] = Rnd.Next(1, 101); } ShowNumbers(lstSource, _numbers); lstSorted.Items.Clear(); } // 5. 排序 private void btnSort_Click(object sender, EventArgs e) { if (_comparer == null) { _comparer = Ascending; // 兜底,理论上不会走到 } int[] copy = (int[])_numbers.Clone(); BubbleSort(copy, _comparer); ShowNumbers(lstSorted, copy); } // 6. 方向切换 private void rdoAsc_CheckedChanged(object sender, EventArgs e) { if (rdoAsc.Checked) _comparer = Ascending; } private void rdoDesc_CheckedChanged(object sender, EventArgs e) { if (rdoDesc.Checked) _comparer = Descending; } // 7. 显示与排序工具方法 private static void ShowNumbers(ListBox box, int[] data) { /* 见上文 */ } private static void BubbleSort(int[] data, CompareHandler compare) { /* 见上文 */ } } }_numbers声明成readonly是刻意的:数组引用不允许被替换,但元素内容可以改。这样写的好处是防止某处不小心写了_numbers = new int[10]把旧数据整个丢掉,同时又不影响正常的元素赋值。这种细粒度的修饰符选择,在项目变大后会体现价值。
5.2 逐段走读:三个容易被忽略的执行时机
第一处,构造函数里的赋值顺序。InitializeComponent()会把rdoAsc.Checked设成true(因为设计器里设了),这会触发一次rdoAsc_CheckedChanged,此时_comparer还是null,但因为事件方法里写的是_comparer = Ascending,是赋值不是调用,所以不会崩,反而把_comparer初始化了。紧接着构造函数里再赋一次_comparer = Ascending,重复但无害。这个细节说明:事件方法里只做赋值、不做空引用调用,本身就是一种防御。如果事件方法里写的是_comparer.Invoke(...)之类,就崩了。
第二处,排序按钮里的空判断。理论上_comparer一定有值,但万一将来有人重构代码、把构造函数里的初始化删了,这个判断能防止崩溃,并且自动回退到升序,用户至少能看到结果。这种“不可能发生但我们还是防一手”的写法,在交付给他人使用的程序里应该成为默认习惯。
第三处,数据流的方向。生成 → 显示原始 → 排序 → 显示结果,全程_numbers保持不变。这意味着用户可以反复点排序按钮,每次都是基于原始数据重新排,不会出现“排一次升序再排一次降序,结果变了两次方向”的混乱。这个设计看起来理所当然,但如果没做Clone(),行为就完全不是这样了。
5.3 上线前的验证清单
代码跑起来只是第一步,我更看重怎么确认它真的对。下面这张清单是我每次交付类似小工具时都会过一遍的:
| 验证项 | 操作方法 | 期望结果 |
|---|---|---|
| 随机性 | 连续点“生成”5 次 | 每次 10 个数不同,不出现整批相同 |
| 取值边界 | 生成 50 次以上,看是否出现 1 和 100 | 至少各出现一次,且无 0 或 101 |
| 数量固定 | 观察两个列表 | 永远 10 条,不多不少 |
| 升序正确 | 选升序,点排序 | 结果列表从小到大,且元素与原始列表完全一致 |
| 降序正确 | 选降序,点排序 | 结果列表从大到小 |
| 原始数据不变 | 排序后看原始列表 | 顺序与生成时一致 |
| 方向切换生效 | 生成后切降序再点排序,再切回升序点排序 | 两次结果互为逆序 |
| 重复排序无副作用 | 不生成新数据,连点排序 3 次 | 结果始终一致,不会越排越乱 |
“元素与原始列表完全一致”这一条特别值得强调。排序只改顺序不改内容,如果排序后少了某个数、多了一个数,那一定是复制数组或下标访问写错了。用两个列表做对比,一眼就能看出来。
6. 常见问题排查与实战避坑
6.1 症状对照表:从现象反推原因
调试这类程序,最快的路径是建立“现象 → 原因”的映射。下面这些是我在答疑和 code review 里反复见到的:
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 10 个数完全相同 | new Random()写在循环内 | 把Random提到循环外或字段 |
| 连着点生成,结果一模一样 | 同上,或框架老版本Random复用 | 检查种子来源 |
| 100 从不出现 | Next(1, 100)上界写错 | 改为Next(1, 101) |
| 点排序没反应 | 委托字段为null或未挂接 | 断点看_comparer是否为空 |
| 排出来是 1, 10, 100, 2 | 按字符串比较了 | 检查比较的是int还是string |
| 升降序结果一样 | 排序时直接改了原数组,没Clone | 检查是否Array.Sort(_numbers) |
| 第一轮正常,第二轮就乱 | 排序结果回写到了原数组 | 同上,确认数据源没被污染 |
| 窗体还没显示就报异常 | 事件在构造阶段触发,字段还未初始化 | 字段声明处给默认值,事件里加判断 |
| 点一下按钮执行了两次 | 事件重复订阅 | 搜索+=,确认没有重复绑定 |
| 选降序但实际还是升序 | 用了+=导致多播,返回值取最后一个 | 改成= |
“第一轮正常、第二轮就乱”这个症状特别有迷惑性,很多人会怀疑是随机数生成出了问题,实际上是数据源被排序就地修改了。判断方法很简单:排序后先看原始列表的顺序有没有变,变了就一定是原地排序。
6.2 几个只有动手做过才会知道的细节
关于委托的调试体验。委托变量在调试器里看起来就是一个对象,展开能看到Method和Target。如果发现Method.Name是空的或者指向了意料之外的方法,基本就是赋值路径出了问题。我习惯在排序方法的入口加一句临时日志,把compare.Method.Name打出来,一眼就能确认当前挂的是Ascending还是Descending。比在单选按钮里打断点高效得多。
关于 lambda 和委托混用。如果哪天把_comparer(a, b)改写成直接Array.Sort(copy, (a, b) => a.CompareTo(b)),会发现原来的方向切换失效了——因为方向被硬编码进 lambda 里了。这种“重构把功能重构没了”的情况,根源是委托变量被绕开。只要方向需要运行时切换,比较逻辑就必须通过委托变量间接调用,不能直接写在调用点上。
关于界面卡顿的预防。本题只有 10 个元素,怎么排都是瞬间完成,不考虑性能。但如果哪天把需求改成 10 万个随机数,就需要注意:排序过程放在 UI 线程里执行,界面会假死。正确做法是把排序扔到Task.Run里,完成后用Invoke或BeginInvoke回到 UI 线程更新列表。这里的关键是跨线程操作控件必须回到 UI 线程,直接在后台线程里改ListBox.Items会抛InvalidOperationException。很多人第一次遇到这个异常时一头雾水,其实异常信息已经写得很清楚了。
关于代码整洁的取舍。有人会问,为什么不把Ascending/Descending写成 lambda 表达式赋给字段,比如_comparer = (x, y) => x.CompareTo(y);。这样写确实更短,但调试时Method.Name会显示成<>c__DisplayClass之类的编译器生成名,看不出是升序还是降序。用命名方法虽然多写几行,但可读性和可调试性都更好。我倾向于在工具类里写命名方法,在只调用一次的临时场景里用 lambda。
关于窗体的健壮性。生成按钮和排序按钮都应该考虑“用户乱点”的情况:先生成再排序是正常路径,没生成就点排序呢?由于_numbers初始都是 0,排出来是一堆 0,虽然不崩但体验很差。可以在btnSort_Click里加一个判断,如果lstSource.Items.Count == 0就弹个提示或者直接返回。这种防御看起来很啰嗦,但界面程序最终是给人用的,用户的操作路径永远比你想象的野。
7. 这套思路还能往哪延伸
委托在这个小例子里只是用来切换比较方向,但它能承载的东西远不止于此。把比较方法换个实现,就能实现“按个位数升序、个位数相同时按原值降序”的复合排序;把返回类型换成bool做成筛选委托,就能在生成后按条件过滤(比如只留下偶数);把委托字段换成Action<int[]>,就能在每次排序完成后执行一段回调,比如记录日志或者播放提示。这些变体的共同点是:主流程不动,只替换传进去的方法。
更实际一点的方向是把这套东西和IComparer<int>打通。写一个实现IComparer<int>的类,把比较逻辑放进Compare方法,然后既能用在Array.Sort上,也能用在List<T>.Sort、SortedList上,甚至能塞进数据绑定控件里做自定义排序。字段名、方法名、委托签名都不变,只多了一个类,代码的复用面却大了一圈。等哪天需要给表格控件加“点列头排序”的功能时,这套积累能直接派上用场。
另外值得一提的是,很多人学完委托会觉得它是个抽象概念,用不上。其实只要你在 WinForms 里写过button.Click += ...,就已经在用委托了;写过list.Where(x => x > 0)这种 lambda,也是在塞委托。把它当成“可以传递的方法”而不是“某种高级语法”,接受起来会顺畅得多。这个小练习的价值就在这儿:用一个能一眼看出结果的场景,把“委托变量可以在运行时指向不同方法”这件事坐实。等这个认知立住了,再去读框架源码里那些Func、Action、Predicate,就不会觉得陌生。
我个人在实际操作中的体会是,这类看起来简单的练习题,真正值得花时间的不是“让它跑起来”,而是“让它跑对,并且我能解释为什么这么写”。随机数的种子、上界差一、事件的触发时机、Clone的必要性、字符串比较的陷阱,这五个点每一个都能单独出一次 bug,而它们在这道题里全部出现了。能把它们一次讲清楚,比写十个能跑的按钮更有价值。