Безопасные шаблоны конструкторов для DependencyObjects (WPF .NET)

В программировании управляемого кода существует общий принцип, часто принудительно применяемый инструментами анализа кода, согласно которому конструкторы классов не должны вызывать переопределяемые методы. Если переопределяемый метод вызывается конструктором базового класса, а производный класс переопределяет этот метод, то метод переопределения в производном классе может выполняться до конструктора производного класса. Если конструктор производного класса выполняет инициализацию класса, то метод производного класса может обращаться к неинициализированным членам класса. Для классов свойств зависимостей следует избегать установки значений свойств зависимостей в конструкторе классов, чтобы избежать проблем инициализации среды выполнения. В этой статье описывается, как реализовать конструкторы DependencyObject таким образом, чтобы избежать этих проблем.

Важно!

Документация по рабочему столу для .NET 7 и .NET 6 находится в стадии разработки.

Виртуальные методы и обратные вызовы системы свойств

Виртуальные методы и обратные вызовы свойств зависимостей входят в состав системы свойств WPF и расширяют универсальность свойств зависимостей.

Базовая операция, например установка значения свойства зависимости с помощью SetValue, вызывает событие OnPropertyChanged и, возможно, несколько обратных вызовов системы свойств WPF.

OnPropertyChanged — это пример виртуального метода системы свойств WPF, который может быть переопределен классами с DependencyObject в своей иерархии наследования. Если задать значение свойства зависимостей в конструкторе, который вызывается во время создания экземпляра класса настраиваемого свойства зависимости, а класс, производный от него, переопределяет виртуальный метод OnPropertyChanged, то метод OnPropertyChanged производного класса будет выполняться до любого конструктора производного класса.

PropertyChangedCallback и CoerceValueCallback — примеры обратных вызовов системы свойств WPF, которые могут быть зарегистрированы в классах свойств зависимостей и переопределены классами, производными от них. Если задать значение свойства зависимостей в конструкторе пользовательского класса свойства зависимостей, а класс, производный от него, переопределяет один из этих обратных вызовов в метаданных свойств, то обратный вызов производного класса будет выполняться перед любым конструктором производного класса. Эта проблема не связана с методом ValidateValueCallback, поскольку он не является частью метаданных свойств и может быть указан только классом регистрации.

Дополнительные сведения об обратных вызовах свойств зависимостей см. в статье Обратные вызовы и проверка свойства зависимостей.

Анализаторы .NET

Анализаторы .NET Compiler Platform проверяют код 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 для регистрации PropertyChangedCallback и CoerceValueCallback.

  2. Конструктор базового класса, который задает новое значение свойства зависимостей, что приводит к вызову метода SetValue. Вызов SetValue активирует обратные вызовы и события в следующем порядке:

    1. ValidateValueCallback — реализован в базовом классе. Этот обратный вызов не входит в состав метаданных свойства зависимостей и не может быть реализован в производном классе путем переопределения метаданных.

    2. PropertyChangedCallback — реализован в производном классе путем переопределения метаданных свойства зависимостей. Этот обратный вызов вызывает исключение, связанное с пустой ссылкой, при вызове метода в неинициализированном поле класса s_temperatureLog.

    3. CoerceValueCallback — реализован в производном классе путем переопределения метаданных свойства зависимостей. Этот обратный вызов вызывает исключение, связанное с пустой ссылкой, при вызове метода в неинициализированном поле класса s_temperatureLog.

    4. событие OnPropertyChanged, которое реализовано в производном классе путем переопределения виртуального метода. Это событие вызывает исключение, связанное с пустой ссылкой, при вызове метода в неинициализированном поле класса s_temperatureLog.

  3. Конструктор без параметров производного класса, который инициализирует s_temperatureLog.

  4. Конструктор с параметрами производного класса, который задает новое значение свойства зависимостей, что приводит к вызову метода SetValue. Так как объект s_temperatureLog теперь инициализирован, обратные вызовы и события выполняются, не вызывая исключение, связанное с пустой ссылкой.

Этих проблем инициализации можно избежать с помощью безопасных шаблонов конструкторов.

Безопасные шаблоны конструкторов

Проблемы инициализации производного класса, продемонстрированные в тестовом коде, можно устранить разными способами, включая:

  • Не устанавливайте значение свойства зависимостей в конструкторе пользовательского класса свойств зависимостей, если класс может использоваться в качестве базового класса. Если необходимо инициализировать значение свойства зависимостей, рекомендуется задать требуемое значение в качестве значения по умолчанию в метаданных свойств во время регистрации свойства зависимостей или при переопределении метаданных.

  • Инициализируйте поля производного класса перед их использованием. Например, используя любой из следующих подходов:

    • Создайте экземпляр и назначьте поля экземпляра в одной инструкции. В предыдущем примере инструкция List<int> s_temperatureLog = new(); избегает позднего назначения.

    • Выполняйте назначения в статического конструкторе производного класса, который выполняется перед любым конструктором базового класса. В предыдущем примере размещение оператора назначения s_temperatureLog = new List<int>(); в производном статическом конструкторе класса позволит избежать позднего назначения.

    • Используйте отложенную инициализацию и создание экземпляров, что инициализирует объекты по мере необходимости. В предыдущем примере создание экземпляров и назначение s_temperatureLog с помощью отложенной инициализации и создания экземпляров позволило бы избежать позднего назначения. Дополнительные сведения см. в статье Отложенная инициализация.

  • Следует избегать использования неинициализированных переменных класса в обратных вызовах и событиях системы свойств WPF.

См. также