DependencyObject 的安全构造函数模式(WPF .NET)

托管代码编程有一个一般原则,通常由代码分析工具强制实施,类构造函数不应调用可重写的方法。 如果基类构造函数调用可重写的方法,并且派生类重写该方法,则派生类中的重写方法可以在派生类构造函数之前运行。 如果派生类构造函数执行类初始化,则派生类方法可能会访问未初始化的类成员。 依赖属性类应避免在类构造函数中设置依赖属性值,以避免运行时初始化问题。 本文介绍如何以避免这些问题的方式实现 DependencyObject 构造函数。

属性系统的虚拟方法和回调

依赖属性虚拟方法和回调是 Windows Presentation Foundation (WPF) 属性系统的一部分,并扩展了依赖属性的多功能性。

使用 SetValue 设置依赖属性值这样的基本操作将调用 OnPropertyChanged 事件,并可能需要多个 WPF 属性系统的回调。

OnPropertyChanged 是 WPF 属性系统虚拟方法的一个示例,可以由在其继承层次结构中具有 DependencyObject 的类重写。 如果在自定义依赖属性类实例化期间调用的构造函数中设置依赖属性值,并且派生自它的类将替代 OnPropertyChanged 虚拟方法,则派生类 OnPropertyChanged 方法将在任何派生类构造函数之前运行。

PropertyChangedCallbackCoerceValueCallback 是 WPF 属性系统回调的示例,这些回调可由依赖属性类注册,并由派生自它们的类重写。 如果在自定义依赖属性类的构造函数中设置了依赖属性值,而一个派生类在属性元数据中重写了该属性的回调函数,那么派生类的回调将在任何派生类的构造函数运行之前执行。 此问题与 ValidateValueCallback 无关,因为它不是属性元数据的一部分,只能由注册类指定。

有关依赖属性回调的详细信息,请参阅 依赖属性回调和验证

.NET 分析器

.NET 编译器平台分析器检查 C# 或 Visual Basic 代码中的代码质量和样式问题。 如果在分析器规则 CA2214 处于活动状态时在构造函数中调用可重写的方法,则会收到警告 CA2214: Don't call overridable methods in constructors。 但是,当在构造函数中设置依赖属性值时,规则不会标记基础 WPF 属性系统调用的虚拟方法和回调。

派生类引起的问题

如果 密封 自定义依赖属性类,或者知道类不会派生自,则派生类运行时初始化问题不适用于该类。 但是,如果创建可继承的依赖属性类,例如,如果要创建模板或可扩展控件库集,请避免从构造函数调用可重写的方法或设置依赖属性值。

以下测试代码演示不安全的构造函数模式,其中基类构造函数设置依赖属性值,从而触发对虚拟方法和回调的调用。

    private static void TestUnsafeConstructorPattern()
    {
        //Aquarium aquarium = new();
        //Debug.WriteLine($"Aquarium temperature (C): {aquarium.TempCelcius}");

        // Instantiate and set tropical aquarium temperature.
        TropicalAquarium tropicalAquarium = new(tempCelcius: 25);
        Debug.WriteLine($"Tropical aquarium temperature (C): " +
            $"{tropicalAquarium.TempCelcius}");

        /* Test output:
        Derived class static constructor running.
        Base class ValidateValueCallback running.
        Base class ValidateValueCallback running.
        Base class ValidateValueCallback running.
        Base class parameterless constructor running.
        Base class ValidateValueCallback running.
        Derived class CoerceValueCallback running.
        Derived class CoerceValueCallback: null reference exception.
        Derived class OnPropertyChanged event running.
        Derived class OnPropertyChanged event: null reference exception.
        Derived class PropertyChangedCallback running.
        Derived class PropertyChangedCallback: null reference exception.
        Aquarium temperature (C): 20
        Derived class parameterless constructor running.
        Derived class parameter constructor running.
        Base class ValidateValueCallback running.
        Derived class CoerceValueCallback running.
        Derived class OnPropertyChanged event running.
        Derived class PropertyChangedCallback running.
        Tropical aquarium temperature (C): 25
        */
    }
}

public class Aquarium : DependencyObject
{
    // Register a dependency property with the specified property name,
    // property type, owner type, property metadata with default value,
    // and validate-value callback.
    public static readonly DependencyProperty TempCelciusProperty =
        DependencyProperty.Register(
            name: "TempCelcius",
            propertyType: typeof(int),
            ownerType: typeof(Aquarium),
            typeMetadata: new PropertyMetadata(defaultValue: 0),
            validateValueCallback: 
                new ValidateValueCallback(ValidateValueCallback));

    // Parameterless constructor.
    public Aquarium()
    {
        Debug.WriteLine("Base class parameterless constructor running.");

        // Set typical aquarium temperature.
        TempCelcius = 20;

        Debug.WriteLine($"Aquarium temperature (C): {TempCelcius}");
    }

    // Declare public read-write accessors.
    public int TempCelcius
    {
        get => (int)GetValue(TempCelciusProperty);
        set => SetValue(TempCelciusProperty, value);
    }

    // Validate-value callback.
    public static bool ValidateValueCallback(object value)
    {
        Debug.WriteLine("Base class ValidateValueCallback running.");
        double val = (int)value;
        return val >= 0;
    }
}

public class TropicalAquarium : Aquarium
{
    // Class field.
    private static List<int> s_temperatureLog;

    // Static constructor.
    static TropicalAquarium()
    {
        Debug.WriteLine("Derived class static constructor running.");

        // Create a new metadata instance with callbacks specified.
        PropertyMetadata newPropertyMetadata = new(
            defaultValue: 0,
            propertyChangedCallback: new PropertyChangedCallback(PropertyChangedCallback),
            coerceValueCallback: new CoerceValueCallback(CoerceValueCallback));

        // Call OverrideMetadata on the dependency property identifier.
        TempCelciusProperty.OverrideMetadata(
            forType: typeof(TropicalAquarium),
            typeMetadata: newPropertyMetadata);
    }

    // Parameterless constructor.
    public TropicalAquarium()
    {
        Debug.WriteLine("Derived class parameterless constructor running.");
        s_temperatureLog = new List<int>();
    }

    // Parameter constructor.
    public TropicalAquarium(int tempCelcius) : this()
    {
        Debug.WriteLine("Derived class parameter constructor running.");
        TempCelcius = tempCelcius;
        s_temperatureLog.Add(tempCelcius);
    }

    // Property-changed callback.
    private static void PropertyChangedCallback(DependencyObject depObj, 
        DependencyPropertyChangedEventArgs e)
    {
        Debug.WriteLine("Derived class PropertyChangedCallback running.");
        try
        {
            s_temperatureLog.Add((int)e.NewValue);
        }
        catch (NullReferenceException)
        {
            Debug.WriteLine("Derived class PropertyChangedCallback: null reference exception.");
        }
    }

    // Coerce-value callback.
    private static object CoerceValueCallback(DependencyObject depObj, object value)
    {
        Debug.WriteLine("Derived class CoerceValueCallback running.");
        try
        {
            s_temperatureLog.Add((int)value);
        }
        catch (NullReferenceException)
        {
            Debug.WriteLine("Derived class CoerceValueCallback: null reference exception.");
        }
        return value;
    }

    // OnPropertyChanged event.
    protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
    {
        Debug.WriteLine("Derived class OnPropertyChanged event running.");
        try
        {
            s_temperatureLog.Add((int)e.NewValue);
        }
        catch (NullReferenceException)
        {
            Debug.WriteLine("Derived class OnPropertyChanged event: null reference exception.");
        }

        // Mandatory call to base implementation.
        base.OnPropertyChanged(e);
    }
}
    Private Shared Sub TestUnsafeConstructorPattern()
        'Aquarium aquarium = new Aquarium();
        'Debug.WriteLine($"Aquarium temperature (C): {aquarium.TempCelcius}");

        ' Instantiate And set tropical aquarium temperature.
        Dim tropicalAquarium As New TropicalAquarium(tempCelc:=25)
        Debug.WriteLine($"Tropical aquarium temperature (C): 
            {tropicalAquarium.TempCelcius}")

        ' Test output:
        ' Derived class static constructor running.
        ' Base class ValidateValueCallback running.
        ' Base class ValidateValueCallback running.
        ' Base class ValidateValueCallback running.
        ' Base class parameterless constructor running.
        ' Base class ValidateValueCallback running.
        ' Derived class CoerceValueCallback running.
        ' Derived class CoerceValueCallback: null reference exception.
        ' Derived class OnPropertyChanged event running.
        ' Derived class OnPropertyChanged event: null reference exception.
        ' Derived class PropertyChangedCallback running.
        ' Derived class PropertyChangedCallback: null reference exception.
        ' Aquarium temperature(C):  20
        ' Derived class parameterless constructor running.
        ' Derived class parameter constructor running.
        ' Base class ValidateValueCallback running.
        ' Derived class CoerceValueCallback running.
        ' Derived class OnPropertyChanged event running.
        ' Derived class PropertyChangedCallback running.
        ' Tropical Aquarium temperature (C): 25

    End Sub
End Class

Public Class Aquarium
    Inherits DependencyObject

    'Register a dependency property with the specified property name,
    ' property type, owner type, property metadata with default value,
    ' and validate-value callback.
    Public Shared ReadOnly TempCelciusProperty As DependencyProperty =
        DependencyProperty.Register(
        name:="TempCelcius",
        propertyType:=GetType(Integer),
        ownerType:=GetType(Aquarium),
        typeMetadata:=New PropertyMetadata(defaultValue:=0),
        validateValueCallback:=
            New ValidateValueCallback(AddressOf ValidateValueCallback))

    ' Parameterless constructor.
    Public Sub New()
        Debug.WriteLine("Base class parameterless constructor running.")

        ' Set typical aquarium temperature.
        TempCelcius = 20

        Debug.WriteLine($"Aquarium temperature (C): {TempCelcius}")
    End Sub

    ' Declare public read-write accessors.
    Public Property TempCelcius As Integer
        Get
            Return GetValue(TempCelciusProperty)
        End Get
        Set(value As Integer)
            SetValue(TempCelciusProperty, value)
        End Set
    End Property

    ' Validate-value callback.
    Public Shared Function ValidateValueCallback(value As Object) As Boolean
        Debug.WriteLine("Base class ValidateValueCallback running.")
        Dim val As Double = CInt(value)
        Return val >= 0
    End Function

End Class

Public Class TropicalAquarium
    Inherits Aquarium

    ' Class field.
    Private Shared s_temperatureLog As List(Of Integer)

    ' Static constructor.
    Shared Sub New()
        Debug.WriteLine("Derived class static constructor running.")

        ' Create a new metadata instance with callbacks specified.
        Dim newPropertyMetadata As New PropertyMetadata(
                defaultValue:=0,
                propertyChangedCallback:=
                    New PropertyChangedCallback(AddressOf PropertyChangedCallback),
                coerceValueCallback:=
                    New CoerceValueCallback(AddressOf CoerceValueCallback))

        ' Call OverrideMetadata on the dependency property identifier.
        TempCelciusProperty.OverrideMetadata(
                forType:=GetType(TropicalAquarium),
                typeMetadata:=newPropertyMetadata)
    End Sub

    ' Parameterless constructor.
    Public Sub New()
        Debug.WriteLine("Derived class parameterless constructor running.")
        s_temperatureLog = New List(Of Integer)()
    End Sub

    ' Parameter constructor.
    Public Sub New(tempCelc As Integer)
        Me.New()
        Debug.WriteLine("Derived class parameter constructor running.")
        TempCelcius = tempCelc
        s_temperatureLog.Add(TempCelcius)
    End Sub

    ' Property-changed callback.
    Private Shared Sub PropertyChangedCallback(depObj As DependencyObject,
        e As DependencyPropertyChangedEventArgs)
        Debug.WriteLine("Derived class PropertyChangedCallback running.")

        Try
            s_temperatureLog.Add(e.NewValue)
        Catch ex As NullReferenceException
            Debug.WriteLine("Derived class PropertyChangedCallback: null reference exception.")
        End Try
    End Sub

    ' Coerce-value callback.
    Private Shared Function CoerceValueCallback(depObj As DependencyObject, value As Object) As Object
        Debug.WriteLine("Derived class CoerceValueCallback running.")

        Try
            s_temperatureLog.Add(value)
        Catch ex As NullReferenceException
            Debug.WriteLine("Derived class CoerceValueCallback: null reference exception.")
        End Try

        Return value
    End Function

    ' OnPropertyChanged event.
    Protected Overrides Sub OnPropertyChanged(e As DependencyPropertyChangedEventArgs)
        Debug.WriteLine("Derived class OnPropertyChanged event running.")

        Try
            s_temperatureLog.Add(e.NewValue)
        Catch ex As NullReferenceException
            Debug.WriteLine("Derived class OnPropertyChanged event: null reference exception.")
        End Try

        ' Mandatory call to base implementation.
        MyBase.OnPropertyChanged(e)
    End Sub

End Class

不安全构造函数模式测试中调用方法的顺序为:

  1. 派生类静态构造函数重写了 Aquarium 的依赖属性元数据,并注册了 PropertyChangedCallbackCoerceValueCallback

  2. 基类构造函数,用于设置新的依赖属性值,从而调用 SetValue 方法。 SetValue 调用按以下顺序触发回调和事件:

    1. ValidateValueCallback,在基类中实现。 此回调不是依赖属性元数据的一部分,不能通过重写元数据在派生类中实现。

    2. PropertyChangedCallback,通过在派生类中重写依赖属性元数据来实现。 此回调在调用未初始化的类字段上的方法 s_temperatureLog时会导致 null 引用异常。

    3. CoerceValueCallback,它在派生类中通过重写依赖属性元数据来实现。 此回调在调用未初始化的类字段上的方法 s_temperatureLog时会导致 null 引用异常。

    4. OnPropertyChanged 事件,该事件是通过重写虚拟方法在派生类中实现的。 当它在未初始化的类字段上调用方法 s_temperatureLog时,此事件会导致 null 引用异常。

  3. 派生类无参数构造函数,该构造函数初始化 s_temperatureLog

  4. 派生类参数构造函数,用于设置新的依赖属性值,从而对 SetValue 方法进行另一次调用。 由于 s_temperatureLog 现已初始化,因此回调和事件将运行,而不会导致 null 引用异常。

这些初始化问题可通过使用安全构造函数模式来避免。

安全构造函数模式

可以通过不同的方式解决测试代码中演示的派生类初始化问题,包括:

  • 如果类可能用作基类,请避免在自定义依赖属性类的构造函数中设置依赖属性值。 如果需要初始化依赖属性值,请考虑在依赖属性注册期间或重写元数据期间将所需的值设置为属性元数据中的默认值。

  • 在使用之前初始化派生类字段。 例如,使用以下任一方法:

    • 在单个语句中实例化和分配实例字段。 在前面的示例中,语句 List<int> s_temperatureLog = new(); 将避免延迟赋值。

    • 在派生类静态构造函数中执行赋值,该构造函数在任意基类构造函数之前运行。 在前面的示例中,将赋值语句 s_temperatureLog = new List<int>(); 放在派生类静态构造函数中将避免延迟赋值。

    • 使用延迟初始化和实例化,这样对象会在首次需要时进行初始化。 在前面的示例中,使用延迟初始化和实例化来实例化和分配 s_temperatureLog 将避免延迟分配。 有关详细信息,请参阅 延迟初始化

  • 避免在 WPF 属性系统回调和事件中使用未初始化的类变量。

另请参阅