一个令人困惑的现象


在调试一段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)。本文将以 ApplicationSettingsBaseDataTable 为例,深入其源码,剖析这种模式背后的设计哲学与实际应用。

关心的不是“值”,而是“状态”


要理解这种行为,必须转变一个观念:对于这类框架组件而言,它们管理的不仅仅是简单的值(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 可以查看:

下面是事件触发的完整连锁反应:

  1. 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; 。

  2. DataColumn 方法的保存值的方法:
    /// <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;
    			}
    		}
    	}
    }
    保存逻辑在 _storage.Set(record, value);
  3. 接着逻辑返回第一步中方法最后的EndEdit() 方法。
    /// <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();
    		}
    	}
    }
    EndEdit方法里最后将事件和状态处理通过_table.SetNewRecord 提交给了 DataTable。
  4. 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属性。

通过这层简单的包装,我们就实现了“仅在值真实改变时才触发事件”的最佳实践。

结语


ApplicationSettingsBaseDataTable 的“赋值即触发”行为,并非缺陷,而是.NET框架中一个优雅且实用的状态驱动事件模式的体现。它将数据容器从简单的值存储器提升为强大的状态机,极大地简化了数据持久化和同步的复杂性。

它选择牺牲微不足道的判断性能,来换取核心功能(状态跟踪)的绝对可靠性。理解了其“关注状态,而非价值”的核心思想,并洞悉其源码实现后,我们就能根据实际场景,明智地选择是利用还是“修正”这一行为,从而编写出更健壮、更高效的.NET应用程序。