同值短路与单一真值源:属性不是命令,状态只有一个家

同值短路与单一真值源:属性不是命令,状态只有一个家

2026-10-20 · 概念 + 语法。今天挑两个概念:① 同值短路 ------为什么「把有副作用的动作挂在属性 setter 上」会被悄悄吞掉;② 单一真值源 ------本地门闩为什么总有一天会和真实状态分叉。

全文脱敏:真实类名 / 方法名 / 产品名 / 厂商名一律泛化;业务步骤名写成泛化词;不含具体耗时与数值。


引子:一个「点两次就好」的怪现象

泛化后的现象:

  1. 点「开」→ 界面毫无反应,日志里却明明记到了这次点击。
  2. 先点一下「关」,再点「开」→ 好了。

第一次遇到会以为是按钮坏了、命令丢了、硬件没响应。实际上它同时踩中两个概念:同值短路 (机制层)和本地门闩与真实状态分叉(设计层)。

先把两个词立住:

  • 同值短路:属性 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);   // 发指令、写硬件、调服务 ------ 全是副作用
}

只要满足下面两条,就迟早出事:

  1. 动作挂在属性(或其变更钩子)上;
  2. 期望值与当前值可能相同 (用户点"开",而属性当前已经是 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      │              │   已关闭      │
        └──────────────┘              └──────────────┘
               ▲                              ▲
               │                              │
        只有按钮会写                  按钮 / 其它流程 / 硬件保护
               │                              │
               └────── 没有任何同步通道 ──────┘
                              ↑
                    分叉不是"会不会",是"什么时候"

事件顺序:

  1. 用户点「开」→ 门闩 true,设备真的开了。
  2. 另一条流程(另一个页面的开关、某个保护逻辑、退出时的收尾)把设备关了------它走的是设备接口,根本不知道有个门闩。
  3. 门闩仍然是 true。
  4. 用户回来点「开」→ 写 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 不短路,命令自然发出去了。

排查清单(可复用)

  1. 点了没反应,但日志(或埋点)记到了点击 → 首先怀疑同值短路:在 setter 入口和变更钩子里各记一行,看钩子有没有被调用。
  2. 钩子没被调用、命令没发出 → 查写入的值与当前值是否相同;查这个属性是否还有别的写入者。
  3. 状态展示与真实状态不同源 → 当成"已经分叉"处理,而不是"偶发异常"。
  4. 要"执行一次"用命令;要"它现在是 X"用属性。
  5. 一个状态有多个写者 → 只读镜像 + 单一真值源 + 明确的通知时机。

附:今天的语法速查

语法 / 机制 要点
[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 前者写属性(可能同值短路),后者执行命令(无条件)

一句话收尾:属性不是命令,状态只该有一个家。 前者决定"点了会不会生效",后者决定"画面上的状态是不是真的"。

相关推荐
下页、再停留5 小时前
【C#桌面客户端系列学习-15】生成可视化图表之折线图、柱状图、饼状图
开发语言·c#
波力海苔夹心脆67511 小时前
VisionMaster 两级位置修正实战:塑料外壳与金属件装配角度检测
经验分享·计算机视觉·c#·visionmaster·海康机器人·位置修正
小羊没烦恼!13 小时前
C#综合揭秘——Entity Framework 并发处理详解
java·开发语言·前端·javascript·数据库·mysql·c#
淡海水14 小时前
05-02-源码-Boehm GC标记阶段
算法·unity·c#·游戏引擎·gc
信可维14 小时前
AI 读 Word 表格总读错?先查合并单元格和表头这 3 处
开发语言·人工智能·c#·word·软件工程
半摆烂日常16 小时前
手机表单的左滑操作:触摸事件的处理细节
大数据·开发语言·人工智能·智能手机·https·c#
绛橘色的日落(。・∀・)ノ16 小时前
C#学习笔记
笔记·学习·c#
asdzx6717 小时前
C# 将 HTML 转换为 PDF:基于 Chrome 的转换方案
pdf·c#·html
1-1=018 小时前
.NET 实现后台任务
c#·.net·.netcore·quartz·hangfire