同值短路与单一真值源:属性不是命令,状态只有一个家
2026-10-20 · 概念 + 语法。今天挑两个概念:① 同值短路 ------为什么「把有副作用的动作挂在属性 setter 上」会被悄悄吞掉;② 单一真值源 ------本地门闩为什么总有一天会和真实状态分叉。
全文脱敏:真实类名 / 方法名 / 产品名 / 厂商名一律泛化;业务步骤名写成泛化词;不含具体耗时与数值。
引子:一个「点两次就好」的怪现象
泛化后的现象:
- 点「开」→ 界面毫无反应,日志里却明明记到了这次点击。
- 先点一下「关」,再点「开」→ 好了。
第一次遇到会以为是按钮坏了、命令丢了、硬件没响应。实际上它同时踩中两个概念:同值短路 (机制层)和本地门闩与真实状态分叉(设计层)。
先把两个词立住:
- 同值短路:属性 setter 发现「新值 == 旧值」就直接返回,整个 setter 体不执行。
- 单一真值源 :一个状态只允许有一个权威来源,其余所有地方都是它的只读镜像。
一、同值短路:setter 里的那道「无变化就返回」
1.1 它长什么样
MVVM 里最常用的写法,是给一个字段打上 [ObservableProperty],让源生成器在编译期补出属性。生成出来的骨架(示意):
csharp
public bool OutputOn
{
get => outputOn;
set
{
// ↓ 这一行就是「同值短路」
if (!EqualityComparer<bool>.Default.Equals(outputOn, value))
{
outputOn = value;
OnPropertyChanged(nameof(OutputOn)); // 属性变更通知
OnOutputOnChanged(value); // 你写的部分方法钩子
}
}
}
用 ASCII 画一下点击那一刻发生了什么:
[点击「开」]
│
▼
写属性:OutputOn = true
│
┌─────────────┴─────────────┐
│ 当前值 = false │ 当前值 = true
▼ ▼
进入 setter 体 同值 → 直接 return
│ │
├─ 赋新值 ├─ 不通知界面
├─ 通知界面 ├─ 不触发变更钩子
└─ 触发变更钩子 └─ 不发命令
│
▼
发出「开」命令
注意右边那一列:不是抛异常,不是报错,是什么都没发生。没有通知、没有钩子、没有命令。所以日志里只有"点击"这一行,后面全是空白。
1.2 类比:电梯按钮
你已经在 5 楼,按一下「5」------电梯什么也不做。这完全合理:它认为你的诉求("我要到 5 楼")已经满足了。
但如果你把"到 5 楼后开门"这个动作挂在「5」按钮上,那么"人在 5 楼时按 5"就永远不开门。按钮没坏,是它执行的是状态断言 ("令我在 5 楼"),而你想要的其实是动作("执行一次到 5 楼")。
1.3 语法逐点
① EqualityComparer<T>.Default.Equals
csharp
EqualityComparer<bool>.Default.Equals(旧值, 新值)
EqualityComparer<T>是 .NET 内置的抽象比较器,Default是它的静态入口属性。- 泛型代码里不能用
==:编译期不知道T有没有定义==运算符。 - 对值类型它会优先走
IEquatable<T>,对引用类型走Equals,并且能安全处理null。 - 判等结果取反再进
if,读作:"只有在不相等时才干活。"
② OnPropertyChanged(nameof(OutputOn))
- 来自基类
ObservableObject;WPF 的数据绑定靠它知道"这个属性变了,去重新取值"。 nameof(OutputOn)是编译期字符串"OutputOn",比你手写字符串安全------属性改名时编译器会一起改。- 注意:它只负责"喊一声",不负责"做事情"。
③ partial void OnOutputOnChanged(T value)
- 源生成器在部分类里声明 了这个部分方法;你在另一个 partial 文件里实现它,生成器就会在 setter 里调用你。
- 只声明不实现也合法,编译器会把调用点整个删掉,运行时零开销。
- 它的语义是"这个值变了",不是"有人点了按钮"。
1.4 判据:什么时候会踩雷
看 setter 或变更钩子里有没有副作用:
csharp
// 危险:把命令挂在了「值真的变了」这个条件上
partial void OnOutputOnChanged(bool value)
{
_device.SetOutput(value); // 发指令、写硬件、调服务 ------ 全是副作用
}
只要满足下面两条,就迟早出事:
- 动作挂在属性(或其变更钩子)上;
- 期望值与当前值可能相同 (用户点"开",而属性当前已经是
true)。
而"属性当前已经是 true、真实设备却已经关了"这种情况,恰恰是第二个概念要解释的。
1.5 修法:把「一次动作」交给命令
csharp
[RelayCommand]
private Task TurnOnAsync() => ApplyAsync(true);
[RelayCommand]
private Task TurnOffAsync() => ApplyAsync(false);
private async Task ApplyAsync(bool on)
{
try
{
await _device.SetOutput(on); // 无条件下发,不经过任何"同值即跳过"
}
catch (Exception ex)
{
Log.Error(ex, "设置输出失败");
}
}
语法点:[RelayCommand] 的生成规则
| 你写的 | 生成的命令 |
|---|---|
void Do() |
DoCommand(同步) |
Task DoAsync() |
DoCommand(异步,去掉 Async 后缀) |
void Do(int n) |
DoCommand(带参数) |
异步命令生成的是 AsyncRelayCommand,它内部自带"执行中"守卫(默认不允许并发重入);同时它实现了 ICommand,所以能直接给 Command 绑定。
界面侧两种写法的本质区别:
xml
<!-- 写法一:点击 → 写属性(进入 setter,可能被同值短路) -->
<Button Content="开">
<i:Interaction.Triggers>
<i:EventTrigger EventName="Click">
<i:ChangePropertyAction PropertyName="OutputOn" Value="True" />
</i:EventTrigger>
</i:Interaction.Triggers>
</Button>
<!-- 写法二:点击 → 执行命令(无条件执行一次) -->
<Button Content="开" Command="{Binding TurnOnCommand}" />
| 写法 | 点击时做什么 | 同值时 | 类型安全 |
|---|---|---|---|
ChangePropertyAction |
写属性 | 被短路,什么都不发生 | PropertyName 是字符串,写错不报编译错 |
Command 绑定 |
执行命令 | 照样执行一次 | 编译期有方法签名 |
记住一句话:属性表达"它应该是 X",命令表达"去做一次 X"。用户点按钮,几乎永远想要后者。
二、单一真值源:门闩终有一天会和真实状态分叉
2.1 分叉是怎么发生的
典型结构:界面模型里存一个 bool 当"门闩",只有按钮写它,既不读真实状态,页面进出也不复位。
本地门闩(副本) 真实状态(设备)
┌──────────────┐ ┌──────────────┐
│ true │ │ 已关闭 │
└──────────────┘ └──────────────┘
▲ ▲
│ │
只有按钮会写 按钮 / 其它流程 / 硬件保护
│ │
└────── 没有任何同步通道 ──────┘
↑
分叉不是"会不会",是"什么时候"
事件顺序:
- 用户点「开」→ 门闩
true,设备真的开了。 - 另一条流程(另一个页面的开关、某个保护逻辑、退出时的收尾)把设备关了------它走的是设备接口,根本不知道有个门闩。
- 门闩仍然是
true。 - 用户回来点「开」→ 写
true→ 同值短路 → 命令没发出 → 现象出现。
2.2 类比:冰箱门上的便利贴
你在冰箱门上贴一张纸条:"里面有牛奶"。后来家人把牛奶拿走了,没改纸条。第二天你看纸条,坚信有牛奶。
问题不在纸条,在于你把纸条当成了事实来源。要么别贴纸条(直接开冰箱看),要么规定"牛奶的每一次增减都必须同时改纸条"------后者听着就累,且总有人忘。
2.3 修法:把属性降成只读镜像
csharp
// 只读计算属性:真值直接来自设备状态
public bool OutputOn => DeviceState?.Code == ReadyCode;
// 它不会自己通知,需要在"输入变了"时手动喊一声
partial void OnDeviceStateChanged(DeviceStatus value)
=> OnPropertyChanged(nameof(OutputOn));
ASCII 看结构:
真实状态(唯一来源)
│
┌──────────────┼──────────────┐
▼ ▼ ▼
界面显示 按钮可用性 业务判断
(全是只读镜像:谁都不再自己存一份)
这里有几个语法点值得展开。
① 表达式体属性(expression-bodied property)
csharp
public bool OutputOn => DeviceState?.Code == ReadyCode;
=>后面直接是表达式,等价于get { return ...; }。- 它没有 setter 。所以任何
OutputOn = true的写法都会编译报错------这是故意的:从语言层面断掉"外部改写"的可能。 ?.是空条件运算符:左边为null时整个表达式返回null(转成bool场景下配合==是安全的)。
② 计算属性不会自动通知
这一点最容易漏。OnPropertyChanged 只在你调用它时才发;计算属性本身没有字段,也没有生成器帮你挂钩子。
所以必须回答:"它的输入是什么?输入变化时通知它"。
csharp
partial void OnDeviceStateChanged(DeviceStatus value)
=> OnPropertyChanged(nameof(OutputOn));
OnDeviceStateChanged同样是源生成器声明的部分方法钩子------只要DeviceState是[ObservableProperty],值变化时就会调用它。OnPropertyChanged(nameof(OutputOn))手动告诉界面:"别看DeviceState了,直接重新读OutputOn吧。"
③ 这个方案的天花板(必须写清楚)
它是引用替换 驱动的:只有 DeviceState 整个对象被换掉,钩子才触发。
csharp
DeviceState.Code = 其它值; // ← 原地改字段:不触发钩子
DeviceState = 新的状态对象; // ← 对象替换:触发钩子
因此落地时要确认:状态通道是"每次投递新实例",还是"复用同一实例改字段"。如果是后者,得让状态对象自己实现属性通知,或者干脆投递新实例。
2.4 顺带一个同源话题:「已开始」不等于「已到位」
同一个原则(判据必须对准真正关心的事实)还有一个常见变体:
csharp
// 错:把「已开始」当成「已完成」
await StartAsync(); // 状态码变成"运行中"就返回了
await ApplyValueAsync(目标值); // 此时硬件往往还没爬到目标值
// 对:状态判据 + 数值判据,两条分开
await StartAsync();
await ApplyValueAsync(目标值);
await WaitUntilAsync(() => ReadBack() == 目标值, 超时);
- "状态码 = 运行中"回答的是:"开始了吗"。
- "回读值 = 目标值"回答的是:"到位了吗"。
- 两者之间隔着一段爬升期。拿第一个当第二个用,就会在最不稳定的时候做最要紧的事。
类比:快递显示"已发货",不代表"已签收"。你要判断能不能用货,得看签收,不是看发货。
同一个错误还有镜像版:用"数值稳定"当作"已开始"的判据------在还没开始的状态下等一个永远不会出现的稳定值。判据要对准问题,而不是对准手边最容易拿到的信号。
三、两个概念是怎么咬合的
把前面两条接起来,就是引子里那个 bug 的完整链条:
设计层:本地门闩与真实状态分叉(概念二)
│
▼
用户点击「开」→ 属性写入的值和当前值相同
│
▼
机制层:setter 同值短路(概念一)
│
▼
没有通知、没有钩子、没有命令 → 点击被静默吞掉
任意一环不存在,bug 都不会出现:
- 如果门闩与真实状态同源 → 点击时值会发生真实跳变,不短路。
- 如果动作走命令而不是属性 → 同值也照样执行。
这也解释了最迷惑人的那句"先关再开就好":关 把门闩写成 false,下一次开 就是 false → true 的跳变,setter 不短路,命令自然发出去了。
排查清单(可复用)
- 点了没反应,但日志(或埋点)记到了点击 → 首先怀疑同值短路:在 setter 入口和变更钩子里各记一行,看钩子有没有被调用。
- 钩子没被调用、命令没发出 → 查写入的值与当前值是否相同;查这个属性是否还有别的写入者。
- 状态展示与真实状态不同源 → 当成"已经分叉"处理,而不是"偶发异常"。
- 要"执行一次"用命令;要"它现在是 X"用属性。
- 一个状态有多个写者 → 只读镜像 + 单一真值源 + 明确的通知时机。
附:今天的语法速查
| 语法 / 机制 | 要点 |
|---|---|
[ObservableProperty] 生成的 setter |
判等 → 赋值 → OnPropertyChanged → OnXxxChanged;同值整体跳过 |
EqualityComparer<T>.Default.Equals |
泛型安全的判等方式;泛型里不能用 == |
partial void OnXxxChanged(T value) |
源生成器声明的钩子;实现即被调用,无实现零开销 |
[RelayCommand] |
方法 → XxxCommand;Task XxxAsync() 生成异步命令,带并发守卫 |
表达式体属性 => |
无字段、无 setter,适合做只读镜像 |
OnPropertyChanged(nameof(X)) |
手动通知;计算属性必须靠它刷新 |
nameof |
编译期字符串,重构安全 |
i:ChangePropertyAction vs Command |
前者写属性(可能同值短路),后者执行命令(无条件) |
一句话收尾:属性不是命令,状态只该有一个家。 前者决定"点了会不会生效",后者决定"画面上的状态是不是真的"。