检查存在,不等于检查在岗
2026-09-26 · 工业上位机开发笔记
今天一整天的活,表面上是三件事,底下是同一句话:
一个检查有没有用,不看它「有没有」,看它「长在哪条路上、挂在什么上」。一件是:有人一句「超了限位也没提示」,牵出一整类从来没系统查过的防呆缺口。
一件是:一个用了很久的故障判据,被拿到另一天的数据上又跑了一遍 —— 零反例。
一件是:一次专项考讲评,把上面这两件事抠成了一套能说出口的方法。
序 · 今天聊了什么
| # | 事 | 一句话 |
|---|---|---|
| 1 | 防呆的两副面孔 | 「失败被吞」和「该拦没拦」是两个问题 |
| 2 | 拦截长在哪条路上 | 同一个危险动作有 N 个入口,拦截只长在其中一两个上 |
| 3 | ⭐ 怎么把这种缺口扫出来 | 信号是对称性不一致:抄送了五个人,总有一个漏了 |
| 4 | ⭐ 判据要挂在什么上 | 两条禁令 + 一条正解:挂在「状态迁移」上 |
| 5 | 假阳性与假阴性 | 一对跷跷板,改一边最容易顺手带出另一边 |
| 6 | 证据的三种强度 | 指纹 > 结果 > 时间 |
| 7 | 两个数据源对不上 | 先比时间覆盖,再怀疑事件 |
| 8 | 计数要带样本窗 | 同一句「N 次」,换了窗口差一个量级 |
| 9 | 结论能说多远 | 代码层判不了的事,不许对外说 |
| 10 | 语法:条件编译 / 前置守卫 / TryParse / 断言 | 今天真正用到的 C# |
第一部分 · 概念:防呆的两副面孔
1.1 先把两件事分开
防呆这个词,过去我们用得太笼统,把两种完全不同的问题混在一个词里:
┌──────────────────────────────────────────────┐ │ ① 失败被吞 : 出了事,事后看不见 │ │ ② 该拦没拦 : 事还没发生,事前没人挡 │ └──────────────────────────────────────────────┘①是可见性问题,②是覆盖问题。一个系统可以把①修得很好 —— 异常全都记了日志、弹了提示、上报了 —— 而②一处都没有:一个本来就不该被允许的动作,照样一路下发给设备。
而且这两类问题的修法完全不通用:①的修法是「别吞异常、别只记不报」,②的修法是「在动作的入口上装守卫」。用①的眼光去看②,你会得出「我们做得挺好的」这种结论,因为异常确实都被记录下来了 —— 记录的是「动作执行失败了」,而不是「这个动作本来就不该执行」。
比喻:①相当于楼里装了监控,出了事能回放;②相当于门口该有的门禁。
监控再全,也拦不住一个本来就不该进的人 —— 它只负责事后告诉你「他进来过」。
反过来,门禁再严,也替代不了监控:进了门之后发生的事,门禁一无所知。
这两样东西不是一回事,也不能互相替代。
今天这次扫描,专门去查②。它此前从来没被系统查过 —— 之前几轮审计查的全是①。
1.2 拦截长在哪条路上
第一章的例子:一个「危险动作」有几个入口?
┌─► 入口 1(手动点一下)──► [ 有前置检查 ✅ ] ──► 执行 危险动作 ◄──────────┤ └─► 入口 2(自动流程跑)──► [ 无 ❌ ] ──► 执行 ↑ 拦截长在「谁点的」那条路上, 不长在「这个动作」本身上。手动那条路有人盯着,出事了自己负责,所以当年顺手加了检查;自动那条路是机器自己跑的,没人看,反而没有。
比喻:一栋楼三个门,只在正门装了读卡器。
装门禁的人汇报时完全可以诚实地说「我们装了门禁」—— 这句话一个字都不假,
但它挡不住从侧门进来的人。「装了」和「挡住了」之间,隔着「装在哪」。
这个例子最扎人的地方在于:它不需要谁偷懒,也能自然长出来。手动路径是给人用的,写的人会本能地想到「人要犯错」;自动路径是机器用的,写的人会本能地假设「机器不会乱来」。两个假设都合理,合起来就是一个洞。
1.3 ⭐ 找这种缺口的方法:对称性不一致
这类缺口怎么系统性地找?今天摸出来一个核心信号:
同一个校验、同一个前置,在被复制到 N 条同族路径上时,总有一条漏了。
「同族」是关键 —— 不是漫无目的地读代码,而是专挑那些在仓库里出现了一次以上的检查,然后反查:跟它同族的其它路径,有没有跟着做。
执行手法四步:
① 先建「正向基线」 全仓找:仓库里已经做对的拦截长什么样? (找那个明确抛异常、明确拒绝的写法) │ ▼ ② 挑其中「重复出现」的校验 同一个校验在两个地方各有一份 → 那它是被当模板抄过的 → 反查:还有第三条同族路径吗? │ ▼ ③ 对「危险动词」全仓穷举 把「能让设备动 / 能让射线源亮 / 能写参数」的那几个调用点全列出来 → 逐个看它前面有没有前置 │ ▼ ④ 追到「最后一层」才定性 上层没查,可能是「调用方已保证」; 追到真正把参数写下去的那一层还没查,才是事实。第④步今天特别值钱。同一条调用链上,前面几层都还能辩解 ——「上层已经保证过了」「这里只是转发」。只有追到真正动手的那一层,才没有辩解空间:参数直写、立刻启动、中间一个判断都没有。
而同一个类里,软限位的属性就明明白白摆在那几行,只是那两个定位方法一次都没读过它。
比喻:不是「家里没有灭火器」,是灭火器挂在墙上、标签朝里、从来没被谁伸过手。
有和用之间,差的是一次接线。
未命中也要如实记。这次扫的四个方向里,有一个方向(某个角度参数的范围校验)查下来是做对的—— 统一在一个 helper 里,两条调用路径都老老实实调了它。不是所有对称性猜测都成立;只报命中的那次扫描,是没法判断它准不准的,因为你不知道它一共猜了几次。
1.4 三个条目与危害排序
这一轮实扫出三个条目,两条证据已经坐实、一条只有疑点:
| 条目 | 缺的是什么 | 证据强度 | 危害 |
|---|---|---|---|
| A | 一个界面上输入的定位目标值,超出行程范围全程无拦截、无提示,照样下发 | ✅ 已坐实 | 撞机械 |
| B | 一个安全联锁只长在手动路径上;能触发它的若干入口里,多数没有前置检查 | ✅ 代码层已坐实;硬件侧有无独立回路未知 | 人身安全 |
| C | 一处联锁的测试断言与源码对不上 —— 疑似测试根本没在跑 | ⚠️ 待实测 | 掩盖 A/B 类问题 |
排序按危害:B > A > C。
排序本身就是一条方法论:按后果分,不按「哪个更容易改」分。B 的后果是人,A 的后果是设备,C 的后果是「你的检查手段本身可能失效」—— 它不直接伤人,但它会让 A 和 B 在自动检查里静默通过。
C 这一类最容易被忽略,因为它不长得像 bug:
// 测试里写着(示意)Assert.Equal(0,counter.CallCount);// 断言:一下都没调而源码里那句联锁调用是活的,按源码推演这个计数应该等于 1。断言和实现对不上,只有两种可能:① 代码后来恢复了联锁、断言忘了改回来;②这个测试项目根本没在跑。
两种可能都不下结论,标「待实测」。因为往下猜一步就是在编。
比喻:这是烟雾报警器的年检表上盖着「通过」,但报警器其实没通电。
表是真的、章是真的、检查也是真的 —— 只是检查的对象和它声称的不一样。
1.5 一处自我留痕
今天我在这一部分犯过一个错,值得留一笔:
一开始我看到两个计数对不上(一个数据源报的多、另一个报的少),得出的结论是「证据可疑但不充分」。错的不是数字,是判断方式—— 我没有先去按已有的分型判据把这几条记录分个类,就把「数量少」当成了「判据不可靠」。
这件事的直接教训放在 3.2 讲。这里只留一句:「数字对不上」和「判据不成立」之间还隔着好几步推理,不能一步跨过去。
第二部分 · 概念:判据要挂在什么上
上午那一轮是在复验一个用了很久的故障判据。结论是「零反例」,但过程里有一整套判断标准值得单独讲 —— 它比结论本身通用。
2.1 两条禁令 + 一条正解
一个用来判断「正常 / 异常」的判据,不能挂在什么上?
❌ 禁令 a:不许挂在「每轮都有」的标记上 → 这个标记正常轮也打印、异常轮也打印 → 它区分不了好坏,只会把真信号淹掉 → 症状:误报(假阳性) ❌ 禁令 b:不许挂在「新版本会删掉」的标记上 → 某个版本之后这个标记不再打印了 → 判据变成「条件恒真」→ 永远报「设备没问题」 → 症状:看不见的失灵(假阴性) ✅ 正解:挂在「状态迁移」上 → 本该从 A 走到 B,结果却变成了 C → 只挑出「没按剧本走」的那些轮禁令 a 今天的原话可以更狠一点:判据挂在了一个「每轮都会出现」的标记上—— 那就等于每轮都命中,等于没有判据。
禁令 b 是我今天亲眼撞上的:换过一个版本之后,判据里依赖的那个标记不再打印了。「没有它」这件事于是永远为真,判据欢快地报了一整段时间的「无故障」。这是最阴的一种坏法 ——它不报错,它报平安。
比喻:家里装了个火灾报警器,结果它对着烟囱(禁令 a)—— 天天响,真着火的时候没人信;
或者它接在了一根明天就要拆掉的线上(禁令 b)—— 灯是绿的,纯粹是因为它已经感受不到任何东西了。
正解为什么是「状态迁移」:因为状态迁移是「本该」和「实际」的差,它天然带着一个对照组 —— 正常的轮长什么样、这轮长什么样,一比就出来了。而单个标记只告诉你「它出现过」,不告诉你「它出现得对不对」。
2.2 ⭐ 一个判据的复核,长什么样
今天这个判据的复核过程,本身就是它值得信的理由:
① 它原本是在一段时间的数据上得出的(有它的样本窗) │ ▼ ② 今天换了一份「完全在它样本窗之外」的数据来跑 │ ▼ ③ 结果:该命中的全命中,该排除的全排除,零反例 │ ▼ ④ 再用一个「完全不依赖这个判据」的独立算法数一遍 (用两个总量相减,看看差出来几个) │ ▼ ⑤ 两条路数出来的结果互相吻合 —— 这才叫「互相校验」第④步是关键。如果只有一个算法给出了「异常几次」这个数,你没法排除这个算法本身有问题。但换一条完全不同的路再数一遍,两条路对上了,可信度就不是加一倍,是换了个量级。
比喻:一个人报账说本月花了 100 块。
你让他把每笔小票拿出来对(同一份数据的两种读法)—— 对得上,是内部一致;
你自己再翻一遍流水(另一条独立的路径)—— 也对得上,那才叫复核。
只有内部一致没有独立复核,两个人一起错的可能性是存在的。
2.3 假阳性与假阴性是一对跷跷板
今天三处地方都撞上了这个跷跷板:
判据太松 判据太紧 (挂在每轮都有的标记上) (挂在会被删掉的标记上) │ │ ▼ ▼ 误报淹没真信号 真的故障一条不报 (假阳性) (假阴性) │ │ └──────────► 你要的那条线 ◄────────┘ (状态迁移)你今天往哪边修,就最容易顺手带出另一边的毛病。所以改判据之前先问自己一句:
「我这次到底是在修哪个方向?」
判据紧的那版死了(假阴性),你今天把它放松一点 —— 那你要防的是假阳性,不是又把它做回去了。今天讲评里有道题打的就是这个:一个判据只覆盖了绝大多数形态,还差最后一种。正确答案不是「它有噪声」,也不是「把那一例剔掉」,而是「它还没覆盖全部形态,只能算部分可用」—— 差的那一例是信息,不是噪声。
(顺带一条口径:报成绩的时候先报排除、再报命中。先说「全部无害样本都被正确排除」,再说「全部致命样本都被命中」—— 顺序反了,听的人第一反应是「那你误报了多少」。)
2.4 判据要按版本分段
一个之前在用的判据,为什么今天差点失效?因为它依赖的标记,在某个版本之后不再打印了。
把几轮排查拼起来,得到一个通用结论:
| 时期 | 可用判据 |
|---|---|
| 旧版本 | 依赖那个「进曝光」标记 + 一个状态标记,两个都缺 |
| 换 SDK 之后 | 不能用那个标记了;改用「等不到某个状态就直接回退就绪」这一条 |
| 换固件之后 | 某条警告整个消失,出现另一条超时提示(那条不是故障,是一次超时后的自动重发补回) |
| 通用(怎么换都不依赖) | 「回退了就绪却不出图」这个状态迁移本身 |
任何判据换版本后,都要先在旧数据上回验一遍,再往新数据上套。今天正是这条救了一次场:先用旧判据在旧数据上跑出零反例,才敢确认「它在旧版本区间里是有效的」,然后才谈「换版本之后怎么办」。
比喻:老式温度计靠水银柱,电子温度计靠热敏电阻。
你换了一支表,不能拿新手表的刻度去读旧水银柱的数据 ——
也不是新手表坏了,是它俩量的不是一个东西。
2.5 找对了观测手段 ≠ 堵住了故障
今天还多找到一个观测指标:某条错误信息是逐帧打印的,所以它出现的条数 = 这一轮实际到达的帧数。
这个指标好用在哪?它是计数型的,不是「某一行出现过 / 没出现过」的单点标记。
- 正常跑满的一轮:条数 = 该轮的帧数;
- 出故障的那一轮:只剩上一轮尾巴留下的那一帧 →条数很少,但不是零。
「不是零」这一点很重要 —— 它给出的数字能拿去和另一侧的遥测互相对账,两侧各说各话的时候能立刻发现。
但必须把话说死:
这是一个观测手段,不是一个修复手段。
它让你看见得更清楚,它没有让故障少发生一次。
比喻:你换了一支更准的体温计,你量出来的发烧温度更可信了 ——
但体温计不负责退烧。别因为「现在能看清楚了」就把这件事标记成「进展」。
第三部分 · 概念:结论能说多远
3.1 证据的三种强度:指纹 > 结果 > 时间
同一个结论,可以靠三种不同强度的证据支撑:
时间相邻 < 结果变好 < 指纹相同 (弱) (中) (强) 「换完它之后 「换完之后 「两份日志除了时间戳、 就没再犯过」 确实好了」 线程号之外,逐字相同」- 时间相邻:最弱。今天正好有个反例 —— 两个变化、两个时间点,如果只看「换完之后好了」,很容易把功劳记错给其中一个。要一一对上:哪个变化出现在哪个时间点之后。
- 结果变好:中等。但「好了」有可能是因为别的变量一起变了。
- 指纹相同:最强。同一个缺陷每次发作时,留下的痕迹逐字相同—— 这几乎就把「随机性」排除掉了,可以定性为确定性缺陷。
比喻:破案的时候,「他最后跟受害人吃过饭」是弱证据(时间相邻),
「他换了一辆车之后就再没出过事」是中等证据(结果变好),
「现场指纹是他」才是强证据(指纹相同)。
前两个能让你锁定嫌疑人,只有第三个能让你定案。
今天用这条工具,把一个「反复出现的同类事件」定性了下来:它们留下的痕迹,除了时间戳和线程号之外逐字相同—— 所以不是「碰巧又坏了」,是同一个缺陷在反复发作。
(反面用法也成立:要说「这不是巧合」,你就得能说出「正常轮之间的日志是有个体差异的」—— 有对照组,差异才叫差异。)
3.2 ⭐ 两个数据源对不上,先比时间覆盖
今天解开的那个「表面矛盾」值得单独讲:
设备侧日志 ████████████████████████ 一直是满的 上位机日志 ████████████░░░░░░░░░░░░ ██████████ ↑ 这一段没有日志某一侧说那天发生了异常,另一侧的记录里却没有。
第一反应几乎必然是「设备侧多报了一次」或者「数据源不可靠」。正确的第一动作却是:
先去比两边的时间覆盖。
一比就明白了:上位机那份日志,那天的某段时间完全空白(文件按大小滚动,两头都截掉了)。那一次异常,正落在这个洞里。
而且把全盘的日志都翻了一遍,这段时间的记录别处没有副本—— 也就是说那一笔是永久缺失,不是「没发生」。
这条教训要写死:
两个数据源计数对不上时,先查两边的时间覆盖,再怀疑事件本身。
「一边说发生了、另一边说没有」最常见的解释是另一边在那段时间没有日志,不是说有的那边误报。
比喻:两个人对账,一个说这个月有 30 笔支出,一个说 29 笔。
先别急着说谁记错了 ——先看看第二个人那本账本,是不是中间缺了几页。
(同类坑今天还有一个镜像版:只扫一个目录,漏掉了别处的记录。这次是目录扫全了,但时间轴上有洞。)
3.3 计数必须带样本窗
今天定了一条新规矩,我觉得它会长期有用:
任何一个计数,必须同时写清「哪段时间、哪些日志」。
原因很直白 —— 同一句「N 次」,换了窗口可能差一个量级。举个跟本行无关的例子,某个服务上线后统计「报错次数」:
| 说法 | 样本窗 | 和上一行是同一件事吗 |
|---|---|---|
| 「累计报错 42 次」 | 上线至今 8 个月 | — |
| 「本周报错 6 次」 | 最近 7 天(与上一行完全不同的区间) | 不是 |
| 「昨天出现了 2 次」 | 单日全天,而且落在这两个窗之外 | 不是 |
三句话可以同时成立。但如果你把它们抄进同一张表、只留「次数」那一列,读的人会以为它们在互相对账 —— 于是「总数对不上」这个假矛盾就诞生了。
不写窗口,这些数字互相之间没法核对,也没法证伪。一个不可复核的数字,在报告里的价值接近于零 —— 甚至比零更糟,因为它看起来是可复核的。
比喻:「我这个路口从来没出过事故。」
这句话得先问一句「这路通车多久了?」——
通车三天没出事,和通车三十年没出事,是两句话。
(顺带一条:同一件事的两个数对不上时,先看哪个数更旧。今天就有一次,是旧快照里落下的那一笔 —— 后来补齐了,旧数没跟着改。这类「早于当前口径的旧快照」是数字类记录里最典型的过期形态。)
3.4 结论能说多远:代码层判不了的事,不许对外说
B 条目那条发现,我写下来的时候专门加了一段边界声明,大意是:
本机代码无法判定设备侧有没有独立的安全回路。工业设备的常见做法,是把这类联锁做在独立于上位机的硬件回路里 —— 那种情况下,上位机软件不需要、也不应该参与。
- 若硬件侧已有独立回路 → 上位机缺这一层属于冗余缺失 + 无提示,风险降级为「体验差、拦不住无效作业」;
- 若硬件侧没有 → 那是最高等级的后果。
必须先现场确认,再定严重性。在确认之前,不得对外声称结论。
这段声明不是自我保护的套话,它是结论正确性的一部分。代码能看到的是代码,看不到的是硬件 ——把「我没看到」说成「它不存在」,是这类审计里最容易犯、后果最重的一个错。
比喻:你在自己家查线路,发现你家配电箱里没有保险丝。
这不能推出「整栋楼没有保护」—— 总闸可能在楼道的配电间里,你看不见。
你要说的是「我这侧的配电箱里没有」,然后去楼道看一眼,再下结论。
3.5 口径纪律(讲评里抠出来的四条)
这一部分是讲评里最像「表达技巧」、其实是判断力的一部分的东西:
| # | 纪律 | 为什么 |
|---|---|---|
| 1 | 主动补限定词 | 「到目前为止没有出现」后面主动跟一句「仍在观察中」。自己先说,比被追问再补,可信度差一个量级 |
| 2 | 报可核对的原始量,不报自己推的派生量 | 「跑了 N 轮」比「折合 X 个工作日」强 —— 后者是你算的,别人复核不了 |
| 3 | 该保守的地方不给向好结论 | 「还没查清」不许说成「可以忽略」;影响面不做小 |
| 4 | 责任切分:先认下自己那段 | 分出「我这侧的」和「不在我这侧的」;不在我侧的那段推不动,从来不妨碍我改我自己的这段 |
第 4 条今天有个很直接的形态:一个问题被分成两条独立的线 —— 一条挂在我方发出的命令之后的日志序列上,另一条挂在设备状态跳变出现在命令之前上。方向相反,所以时间先后本身就是责任判据。
两种处置也不同:一条自己改(而且已经改完了),另一条推不动(只能把问题整理好递出去)。关键是不因为「那条我改不了」就停在这 —— 我这一侧的窗口,是我能关掉的。
比喻:水管漏水,一段在你家、一段在楼道。
楼道那段你确实拧不动 —— 但这不妨碍你今天就把家里那段先拧紧。
「另一半不归我管」是事实,不是停工的理由。
3.6 一个小的命名纪律
讲评里被一句反问逼出来的一条:
动词的主语是谁,要说清。
我们内部说「取图 / 收图」,但设备那一侧的动作其实是「曝光」。「取图」是我方在做的事,「曝光」是设备在做的事 —— 混着说,讲到根因的时候会自然地把责任归错边。
比喻:说「我们收不到货」和说「对方没发货」,是同一个事实的两种说法 ——
但追责的时候,这两句话指向的人不一样。
命名即归因。
第四部分 · 语法:今天用到的 C#
这一部分才是今天真正落到键盘上的东西。上面所有的概念,最后都要变成这几行语法。
4.1#if条件编译 —— 编译期剔除 ≠ 运行期不执行
今天判据失效的那个坑,一半来自这个语法。
// 示意:类名、符号名、方法名均为示意publicvoidOnFrame(IntPtrbuffer){Dispatch(buffer);// 每帧无条件走到这里#ifSAVE_PREVIEWSavePreview(buffer);// 只有定义了 SAVE_PREVIEW 才会被编译进去#endif}为什么详讲这个点:
#if是预处理指令,它在编译之前就被处理完了。没有定义SAVE_PREVIEW的时候,SavePreview(buffer);这一行根本不会进入编译产物—— 它不在 IL 里,不在调试符号里,运行时没有任何痕迹。- 它和
if的区别是根本性的:
#if | if | |
|---|---|---|
| 生效时间 | 编译期 | 运行期 |
| 代码还在吗 | 不在了 | 还在,只是不执行 |
| 调试器能看到吗 | 看不到 | 能看到(断点仍可下) |
| 典型用途 | 不同构建配置走不同代码 | 运行时的条件分支 |
- 什么时候用:需要在构建期就决定「这块代码要不要」的时候 —— 分不同发行版、去掉不想要的调试功能、按目标平台裁剪。这时它比
if好,因为不想要的代码真的不会被打包进去,体积和性能都省下来。 - 常见坑:①诊断看不见它。线上排查时你会理所当然地以为「这段逻辑在跑」,其实它压根没被编进去。②两边都要能看到:同一个符号在不同构建配置下不一致,就会「我这里好的、你那里坏的」。③别拿它当开关:真正需要运行时切换的用
if或配置项,用#if会把「配置」变成「另一个二进制」。
回到今天的场景:判据里依赖的那个标记,是在某个版本里被条件编译掉了(或者等价地,源码里那一段被注释停用了)。判据没有报错,它只是失去了感知能力,然后一路报平安。
—— 这正是 2.1 那条禁令 b 的语法层成因。
4.2 前置守卫 vs 异常兜底
今天那条「超限位无拦截」的根因,用两个写法一摆就清楚了:
// ❌ 异常兜底:先发出去,出错了才提示try{awaitaxis.MoveTo(target);}catch(Exceptionex){ShowMessage(ex.Message);// 事后通知}// ✅ 前置守卫:根本走不出发送这一步if(target>axis.LimitMax||target<axis.LimitMin){thrownewArgumentOutOfRangeException(nameof(target),"目标位置超出行程范围");}awaitaxis.MoveTo(target);为什么详讲这个点:
- 这两种写法的差别不在「有没有提示」,在「提示发生在动作之前还是之后」。
catch里的弹框是事后通知—— 动作已经执行了,副作用已经产生了。这是 1.1 里「①失败被吞」和「②该拦没拦」在语法层的分界。 - 异常类型怎么选,是有讲究的:
| 异常类型 | 什么时候用 |
|---|---|
ArgumentOutOfRangeException | 参数的值超出允许范围(越界、负数、超上限) |
ArgumentNullException | 参数是null |
InvalidOperationException | 参数没问题,但对象当前状态不允许这个操作(没连接、没初始化、顺序不对) |
| 自定义异常 | 调用方需要单独 catch 它并做特殊处理时 |
选错类型的代价:调用方没法可靠地区分「我传错了」和「时机不对」。
- 常见坑:①
catch (Exception ex)一把抓,会把「调用方 bug」和「设备故障」混成一类,上游分不清该重试还是该改代码。②只在 catch 里记日志、不重新抛,等于把异常吃掉(这就是①类问题的语法层写法)。③ 守卫要写在最靠近危险动作的那一层—— 写在上面几层,中间任何一条新加的路径都可能绕过去(这正是 1.3 里「追到最后一层才定性」的原因)。
4.3int.Parsevsint.TryParse
扫描时顺带捞出来的一处:
// ❌ 非数字输入直接抛异常varn=int.Parse(text);// ✅ 解析失败走正常分支,不抛if(!int.TryParse(text,outvarn)){ShowMessage("请输入数字");return;}为什么详讲这个点:
- 两者都是 .NET 标准库的解析 API,区别在失败时怎么表达:
Parse用异常表达失败—— 失败是「异常路径」;TryParse用返回值表达失败—— 失败是「正常路径」。
out参数是这里的语法重点:out var n是 C# 7 起的内联声明写法,等价于先在调用前声明int n;再传out n。out的语义是由被调方负责赋值,所以方法内部所有路径都必须给它赋过值,编译器会检查 —— 这是它比「返回一个int再用魔法值表示失败(比如返回-1)」可靠的地方。- 怎么选:用户输入、外部文件、网络报文这类「失败是常态」的场景用
TryParse;由代码保证一定合法的内部值,用Parse更简洁(失败就是真 bug,让它抛)。 - 常见坑:① 用
Parse处理用户输入,等于把「输错一个字母」变成一次异常 —— 有性能代价,而且会把真正的程序异常淹掉。②TryParse返回false时不能直接用那个out值(它是默认值0),必须先判断返回值。
顺带说回今天的场景:这一处的写法问题(非数字输入无校验、直接抛)和「超限位无拦截」是同一类—— 都是把校验交给了「出事之后」。
4.4 属性摆着没人用:不是没实现,是没接线
今天最有代表性的一个代码形状:
// 示意classAxis{publicdoubleLimitMax{get;set;}// 一直就在这儿publicdoubleLimitMin{get;set;}publicTaskMoveTo(doubletarget)=>Send(target);// 从没读过上面那两个}为什么详讲这个点:
- 这个写法不报错、不警告、编译通过。
LimitMax是个自动属性,有 getter 有 setter,谁都可以读写它 —— 只是MoveTo一次都没读过。 - 也就是说:能力在,接线不在。排查的时候很容易误判成「这个功能没做」,其实是「做了但没人用」—— 这两种情况的修法完全不同:前者要写,后者只要接上。
- 自动属性
{ get; set; }的语法要点:编译器自动生成一个私有匿名字段做后备存储。它和手写字段的区别只在于「你要不要自己控制读写逻辑」—— 需要加校验、加通知(INotifyPropertyChanged)时才展开成手写。 - 常见坑:①软限位这类东西写成可写属性,本身就是个口子—— 谁都能把它改掉。真正想让它可靠,要么在 setter 里加校验,要么干脆做成只读、由配置初始化。② 更根本的一条:限位应该在「最底层那个真正下发参数的方法」里检查一次(4.2 的第③条)—— 放在任何上层,都是在赌「没人绕过我」。
4.5 断言是「期望」,不是「事实」
C 条目那一处的形状:
// 测试里写的(示意)Assert.Equal(0,counter.CallCount);// 期望:一次都没调用为什么详讲这个点:
Assert.Equal(expected, actual)的参数顺序是「先期望、后实际」—— 反了的话,测试失败时的输出信息会误导你(它会把「实际值」当期望值念出来)。有些框架提供Assert.Equal(actual, expected)的重载,用的时候要认准自己这套。- 更重要的一条:断言写的是你以为会发生什么,不是实际发生了什么。这两者不一致的时候,不代表「代码错了」,只代表「有一边过时了」—— 可能是代码变了断言没改,也可能是这个测试根本没被执行。
- 为什么单列这一条:这不是「某个功能没做防呆」,这是防呆的检验手段本身可能失效。它会让 1.4 里那类问题在自动检查里静默通过。
- 常见坑:① 把测试项目当成「一定在跑」的东西 —— 要看它有没有进解决方案、有没有进 CI。②测试项目没进解决方案,是今天另一个元级发现:有些工程文件压根不在
.sln里,那么「编译整个解决方案」就从来不会碰它们。
回到 1.4 的比喻:年检表上盖着「通过」,但表上的这一行,对应的是另一台设备。
4.6 附 · 两条工具语法
这两条不是 C#,但确实是今天的工作现场,单列在这里。
(一)日志编码要按文件探测,不能统一假设
# 示意:拿一个只可能出现在该文件里的中文串去探编码probe="开始"raw=open(path,"rb").read()ifprobe.encode("utf-8")inraw:text=raw.decode("utf-8")elifprobe.encode("gbk")inraw:text=raw.decode("gbk")- 为什么要这样:同一批日志里,设备侧那份是 GBK,上位机侧那份是 UTF-8。按一种编码统一去读,另一份的所有中文串都会数出 0。
- 最坏的地方在于它不报错—— 拿错编码去解码,得到的是一堆乱码或替换字符,程序照样跑完,你会静默地得出一个结论:「这天没有发生过那件事」。这就是 1.1 里说的「失败被吞」出现在你自己的分析脚本里。
- 语法点:
str.encode(enc)把字符串转成字节串,bytes.decode(enc)反过来;判断「子串在不在」时要在同一类型之间比(bytes in bytes),拿str去in一个bytes会直接抛TypeError。
(二)二进制日志先去\0再解码
raw=open(path,"rb").read().replace(b"\x00",b"")# 不去掉,后面会中途停text=raw.decode("gbk","replace")grep-a"关键字"app.log# 不加 -a:含 \0 会被当二进制,计数中途停止- 为什么:日志文件里夹着
\0字节,很多工具会据此判定「这是二进制文件」,然后在第一个空字节那里停下来—— 结果是计数偏小、而且不报错。 - 语法点:
b"\x00"是bytes字面量(前缀b),replace在bytes上同样可用;grep的-a是「当作文本处理」;Python 里同样效果的保险做法是先用二进制读、先把空字节替换掉、再解码。
收尾 · 今天最该记的三条
「装了」和「挡住了」之间,隔着「装在哪」。
防呆不是「有没有」的问题,是「长在哪条路上」的问题。同一个危险动作有几个入口,只给其中一个装了守卫,汇报时照样可以诚实地说「我们有守卫」—— 而那扇门是开着的。判据不许挂在「每轮都有」和「新版会删」的标记上,要挂在「状态迁移」上。
前者会把真信号淹掉(误报),后者会让条件恒真、一路报平安(看不见的失灵)。后一种最危险 —— 它不报错,它报平安。两个数据源对不上时,先比时间覆盖,再怀疑事件;任何一个计数,都要带上样本窗。
「一边说发生了、另一边说没有」最常见的解释是另一边那段时间没有日志。而一个不带窗口的数字,看起来可复核,实际上不可复核 —— 那比没有数字更糟。
本文为学习笔记,代码均为示意,类名、方法名、参数名与数值均已泛化。