一个令人困惑的现象
在调试一段C#代码时,即便是给一个对象的属性赋上与它当前完全相同的值,相关的“已更改”(XXXChanged)事件依然被触发了。一个典型的例子就是在使用 System.Data.DataTable
或自定义的设置类(继承自 System.Configuration.ApplicationSettingsBase
)时。比如:
// 场景一:DataTable
// (实际绑定中,UI响应的是DataRowView的PropertyChanged)
DataTable table = new DataTable("Users");
table.Columns.Add("ID", typeof(int));
table.Columns.Add("Name", typeof(string));
// 添加事件处理程序
table.RowChanged +=(s, e) => Console.WriteLine("RowChanged 事件触发!");
// 添加一行数据
DataRow row = table.NewRow();
row["ID"] = 1;
row["Name"] = "John";
table.Rows.Add(row);
row["Name"] = "admin"; // 第一次赋值,触发事件
row["Name"] = "admin"; // 赋相同的值,竟然又触发了事件!
// 场景二:ApplicationSettingsBase
Settings.Default.PropertyChanged += (s, e) => Console.WriteLine("PropertyChanged 事件触发!");
Settings.Default.UserName = "guest"; // 第一次赋值,触发事件
Settings.Default.UserName = "guest"; // 赋相同的值,竟然也触发了事件!
这似乎“多此一举”且低效,如果值没有实际变化,为何要触发一个通知事件,可能进而引发一系列不必要的UI刷新或业务逻辑呢?
然而,这并非框架的疏忽,而是一种深思熟虑后的设计模式——状态驱动事件(State-Driven Events)。本文将以 ApplicationSettingsBase
和 DataTable
为例,深入其源码,剖析这种模式背后的设计哲学与实际应用。
关心的不是“值”,而是“状态”
要理解这种行为,必须转变一个观念:对于这类框架组件而言,它们管理的不仅仅是简单的值(Value),而是更复杂的状态(State)。
set
赋值操作在这里被解读为一个明确的开发者意图(Intent)。它表达的不仅仅是“请把这个值存起来”,更是“请将这个实体标记为‘已更改’(Dirty)”。框架忠实地响应了这个“指令”,因为这个“脏”状态是其核心功能——数据持久化与同步——的关键依据。
换言之,相关的“Changed”事件,本质上是在宣告:“我管理的某个实体的状态发生了变化!”,而不仅仅是“某个值从A变成了B”。
源码分析
为了验证这一理论,可以直接深入.NET的源码,看看这两个类是如何实现这一行为的。
ApplicationSettingsBase
简单直接的委托
ApplicationSettingsBase
的机制非常直观。当在设置类中定义一个属性时,它的 set
访问器通常如下所示:
[UserScopedSetting]
public string UserName
{
get { return (string)this["UserName"]; }
set { this["UserName"] = value; } // 关键在这里
}
这行代码调用了基类 ApplicationSettingsBase
的索引器。下面是索引器 set
方法的代码:
public override object this[string propertyName]
{
get { /* ... */ }
set
{
SettingChangingEventArgs settingChangingEventArgs = new SettingChangingEventArgs(propertyName, GetType().FullName, SettingsKey, value, cancel: false);
OnSettingChanging(this, settingChangingEventArgs);
if (!settingChangingEventArgs.Cancel)
{
base[propertyName] = value;
PropertyChangedEventArgs e = new PropertyChangedEventArgs(propertyName);
OnPropertyChanged(this, e);
}
}
}
源码清晰地表明,其内部完全没有进行相等性检查。赋值操作被直接翻译为“更新值 + 触发事件”。这种简单、直接的委托机制,确保了任何 set
行为都会被视为一次有效的“修改”并通知外界。
DataTable
精妙的间接通知
与 ApplicationSettingsBase
的直接不同,DataTable
的机制要精妙得多,因为它涉及多个类的协作。这里有一个关键点:DataRow
本身不实现 INotifyPropertyChanged
,真正实现它的是专门用于UI绑定的 DataRowView
。源码在 https://source.dot.net/#System.Data.Common/System/Data/DataTable.cs 可以查看:
下面是事件触发的完整连锁反应:
- DataRow.cs 文件, this[DataColumn column] 索引器的 set 访问器。当您使用列对象作为索引时,会进入此方法。
[AllowNull] public object this[DataColumn column] { get { // ... } set { CheckColumn(column); // ... object? proposed = ((null != e) ? e.ProposedValue : value); // ... bool immediate = BeginEditInternal(); try { int record = GetProposedRecordNo(); _table._recordManager.VerifyRecord(record, this); column[record] = proposed; } catch (Exception e1) when (Common.ADP.IsCatchableOrSecurityExceptionType(e1)) { if (immediate) { Debug.Assert(!_inChangingEvent, "how are we in a changing event to cancel?"); Debug.Assert(-1 != _tempRecord, "how no propsed record to cancel?"); CancelEdit(); } throw; } LastChangedColumn = column; // note: we intentionally do not try/catch this event. // infinite loops are possible if user calls Item or ItemArray during the event if (null != e) { _table.OnColumnChanged(e); // user may call CancelEdit or EndEdit } if (immediate) { Debug.Assert(!_inChangingEvent, "how are we in a changing event to end?"); EndEdit(); } } }
此处的逻辑非常清晰。DataRow 自身不做任何设值操作,而是立即调用 DataColumn 实例的索引器方法 column[record] = proposed; 。
DataColumn
方法的保存值的方法:
保存逻辑在 _storage.Set(record, value);/// <summary> /// This is how data is pushed in and out of the column. /// </summary> internal object this[int record] { get { _table!._recordManager.VerifyRecord(record); Debug.Assert(null != _storage, "null storage"); return _storage.Get(record); } set { try { _table!._recordManager.VerifyRecord(record); Debug.Assert(null != _storage, "no storage"); Debug.Assert(null != value, "setting null, expecting dbnull"); _storage.Set(record, value); Debug.Assert(null != _table, "storage with no DataTable on column"); } catch (Exception e) { ExceptionBuilder.TraceExceptionForCapture(e); throw ExceptionBuilder.SetFailed(value, this, DataType, e); } if (AutoIncrement) { if (!_storage.IsNull(record)) { AutoInc.SetCurrentAndIncrement(_storage.Get(record)); } } if (Computed) { // if and only if it is Expression column, we will cache LastChangedColumn, otherwise DO NOT DataRow dr = GetDataRow(record); if (dr != null) { // at initialization time (datatable.NewRow(), we would fill the storage with default value, but at that time we won't have datarow) dr.LastChangedColumn = this; } } } }
- 接着逻辑返回第一步中方法最后的EndEdit() 方法。
EndEdit方法里最后将事件和状态处理通过_table.SetNewRecord 提交给了 DataTable。/// <summary> /// Ends the edit occurring on the row. /// </summary> [EditorBrowsable(EditorBrowsableState.Advanced)] public void EndEdit() { if (_inChangingEvent) { throw ExceptionBuilder.EndEditInRowChanging(); } if (_newRecord == -1) { return; // this is meaningless, detached row case } if (_tempRecord != -1) { try { // suppressing the ensure property changed because it's possible that no values have been modified _table.SetNewRecord(this, _tempRecord, suppressEnsurePropertyChanged: true); } finally { // a constraint violation may be thrown during SetNewRecord ResetLastChangedColumn(); } } }
- DataTable.cs 文件, internal void SetNewRecord(...) 方法,这是事件触发的“心脏”地带,著名的两阶段事件模型在这里体现。
internal void SetNewRecord(DataRow row, int proposedRecord, DataRowAction action = DataRowAction.Change, bool isInMerge = false, bool fireEvent = true, bool suppressEnsurePropertyChanged = false) { Exception? deferredException; SetNewRecordWorker(row, proposedRecord, action, isInMerge, suppressEnsurePropertyChanged, -1, fireEvent, out deferredException); // we are going to call below overload from insert ......................................................... } private void SetNewRecordWorker(DataRow row, int proposedRecord, DataRowAction action, bool isInMerge, bool suppressEnsurePropertyChanged, int position, bool fireEvent, out Exception? deferredException) { // this is the event workhorse... it will throw the changing/changed events // and update the indexes. Used by change, add, delete, revert. // order of execution is as follows // // 1) set temp record // 2) Check constraints for non-expression columns // 3) Raise RowChanging/RowDeleting with temp record // 4) set the new record in storage // 5) Update indexes with recordStateChanges - this will fire ListChanged & PropertyChanged events on associated views // 6) Evaluate all Expressions (exceptions are deferred)- this will fire ListChanged & PropertyChanged events on associated views // 7) Raise RowChanged/ RowDeleted // 8) Check constraints for expression columns ......................................................... DataRowChangeEventArgs? drcevent = null; try { row._action = action; drcevent = RaiseRowChanging(null, row, action, fireEvent); } catch { row._tempRecord = -1; throw; } finally { row._action = DataRowAction.Nothing; } ......................................................... // reset the last changed column here, after all // DataViews have raised their DataRowView.PropertyChanged event row.ResetLastChangedColumn(); try { if (fireEvent) { RaiseRowChanged(drcevent, row, action); } } catch (Exception e) when (ADP.IsCatchableExceptionType(e)) { ExceptionBuilder.TraceExceptionWithoutRethrow(e); // ignore the exception } }
fireEvent
参数控制是否触发事件。在我们的流程中,它为true
。这里清晰地展示了先触发RaiseRowChanging
,再触发RaiseRowChanged
的顺序。
这个里面可以看到,其内部完全没有进行相等性检查。赋值操作被直接翻译为“更新值 + 触发事件”。这个“委托-监听-翻译-广播”模型虽然复杂,但实现了完美的关注点分离,使得数据层和表现层可以独立演化。
是性能陷阱还是刻意为之?
框架不进行相等性判断,会不会是为了性能?也许 if
判断本身有开销?答案恰恰相反。不作判断并非为了性能优化,而“添加判断”本身才是优化的标准手段。
- 相等性判断,成本可能极低(纳秒级),一个简单的CPU操作,用于避免后续的高昂开销。
- 触发事件及后续处理,成本可能极高( 毫秒级甚至更高),它可能涉及委托链调用和不可控的外部代码执行,是主要的性能消耗点。
用一次纳秒级的廉价判断,去避免一次潜在的、毫-秒级的昂贵操作,是绝对划算的。框架之所以“明知故犯”,是因为功能性压倒了微观性能优化。其首要任务是确保状态跟踪机制的绝对可靠,因此选择了一个功能上最稳健的默认实现,并将优化的责任交给了开发者。
何时利用,何时规避?
理解了这一模式后,我们作为开发者便可以游刃有余地进行处理。这里有两个场景:
利用默认行为
当需要确保任何赋值操作都被记录下来以便持久化时,默认行为正是我们所需要的。
规避默认行为(性能优化)
在大多数UI数据绑定场景中,我们不希望因为赋了相同的值而触发代价高昂的界面重绘。此时,我们就需要夺回控制权,手动添加相等性检查。比如:
public class MySettings : ApplicationSettingsBase
{
// ... 其他代码 ...
[UserScopedSetting]
[DefaultSettingValue("guest")]
public string UserName
{
get { return (string)this["UserName"]; }
set
{
// 在调用基类前,手动进行相等性检查
if ((string)this["UserName"] != value)
{
this["UserName"] = value;
}
}
}
}
这个MySetting有可能是代码设计器自动生成的,不宜直接修改。那么就可以在访问MySetting的类中,首先记录下MySetting对象的UserName旧值,在要进行赋值的时候,手动比较是否发生修改,如果发生修改,则再设置MySetting.UserName属性。
通过这层简单的包装,我们就实现了“仅在值真实改变时才触发事件”的最佳实践。
结语
ApplicationSettingsBase
和 DataTable
的“赋值即触发”行为,并非缺陷,而是.NET框架中一个优雅且实用的状态驱动事件模式的体现。它将数据容器从简单的值存储器提升为强大的状态机,极大地简化了数据持久化和同步的复杂性。
它选择牺牲微不足道的判断性能,来换取核心功能(状态跟踪)的绝对可靠性。理解了其“关注状态,而非价值”的核心思想,并洞悉其源码实现后,我们就能根据实际场景,明智地选择是利用还是“修正”这一行为,从而编写出更健壮、更高效的.NET应用程序。