Борьба с INotifyPropertyChanged или как я стал опенсорсником — 2

    Начиналось все как и в прошлый раз, достаточно прозаично: мне пришлось разработать *-надцать ViewModel-ей для своего MVVM-приложения.
    Для того, чтобы они оптимально работали как ViewModel-и, мои классы должны были наследоваться от DependencyObject или же реализовывать заезженный до дыр интерфейс INotifyPropertyChanged (INPC).

    Давно уже ни для кого не секрет, что DependencyProperty тормознее ручной реализации INPC. Мои тесты показывают, что запись в DependencyProperty в ~13 раз медленнее ручной реализации. Поэтому я, как неисправимый оптимизатор, склоняюсь именно к INPC. Тем более, что код поддержки INPC выглядит логичнее и органичнее, чем описание DependencyProperties.


    Много статей написано на тему того, как облегчить реализацию INPC. Это и вариант с исследованием StackTrace, это и вариант с Lambda-методами, это и code-snippets как персональный code-monkey, это и Resharper, как панацея от ошибок рефэкторинга. Все эти варианты требуют много лишних телодвижений, и мне, как неисправимому оптимизатору рутинных дел, не нравятся.

    Вот, к примеру, вариант реализации с помощью StackTrace:

      public sealed class StackTraceNPC : INotifyPropertyChanged
      {
        string _myProperty;
        public string MyProperty
        {
          get { return _myProperty; }
          set
          {
            if (_myProperty == value) return;
            _myProperty = value;
            RaisePropertyChanged();
          }
        }
    
        /// Этот метод можно вызывать только внутри setter-ов свойств
        void RaisePropertyChanged()
        {
          var e = PropertyChanged;
          if (e != null) 
          {
             var propName = new StackTrace().GetFrame(1).GetMethod().Name.Substring(4);
             e(this, new PropertyChangedEventArgs(propName));
          }
        }
    
        public event PropertyChangedEventHandler PropertyChanged;
      }
    


    А вот пример с expression tree, каждый раз создаваемых компилятором в коде:

      public sealed class LambdaNPC : INotifyPropertyChanged
      {
        string _myProperty;
        public string MyProperty
        {
          get { return _myProperty; }
          set
          {
            if (_myProperty == value) return;
            _myProperty = value;
            RaisePropertyChanged(() => this.MyProperty);
          }
        }
    
        void RaisePropertyChanged<T>(Expression<Func<T>> raiser)
        {
          var e = PropertyChanged;
          if (e != null)
          {
              var propName = ((MemberExpression)raiser.Body).Member.Name; 
              e(this, new PropertyChangedEventArgs(propName));
          }
        }
    
        public event PropertyChangedEventHandler PropertyChanged;
      }
    


    А вот и результаты производительности упомянутых выше реализаций INPC:


    Неисправимые оптимизаторы, глядя на эти цифры, с ужасом вычеркивают из памяти и StackTrace, и Lambda варианты. Понятно, что setter-ы вызываются не так часто, чтобы всерьез задумываться об их производительности, но если речь идет о ViewModel-ях к DataGrid, или о серьезном количестве полей — тогда эти тормоза могут всплыть на поверхность. Кроме того, речь идет не столько о удобном вызове RaisePropertyChanged, сколько об оптимизации всего того геморроя, что с ним связан, включая, проверку на изменение поля и другую писанину типо литералов названий свойств.

    Одним из достойных вариантов был бы AoP-подход на базе PostSharp, но достаточно одного взгляда через Reflector на полученный после компиляции IL-код, чтобы понять — нам и с PostSharp-ом не по пути.

    Здесь пора бы уже и закручиниться… Но вдохновившись статьями о Mono.Cecil насчет инъекции MSIL кода в стороннюю сборку при помощи Mono.Cecil, я решил раз и навсегда решить эту проблему.

    Для начала приведу пример как БЫЛО:

    public class MyViewModel: PropertyChangedBase 
    {
        string _stringProperty;
        public string StringProperty
        {
           get { return _stringProperty; }
           set 
           { 
              if (_stringProperty == value) return;
              _stringProperty = value;
              RaisePropertyChanged("StringProperty");
           }
        }
    
        object _objectProperty;
        public object ObjectProperty
        {
           get { return _objectProperty; }
           set 
           { 
              if (_objectProperty == value) return;
              _objectProperty = value;
              RaisePropertyChanged("ObjectProperty");
           }
        }
    }
    


    Теперь пример как СТАЛО:

    public class MyViewModel: PropertyChangedBase 
    {
        public string StringProperty { get; set;}
    
        public object ObjectProperty { get; set;}
    }
    


    И где же здесь реализация INPC, спросите вы и будете правы. Kind of Magic? Именно, Kind of Magic MSBuild task. Так и называется этот open-source проект на codeplex.

    Весь секрет заключается в базовом классе PropertyChangedBase, своя версия которого есть у каждого из нас :)

    Давайте посмотрим, что же в нем такого особенного:

    [Magic]
    public abstract class PropertyChangedBase : INotifyPropertyChanged
    {
       protected virtual void RaisePropertyChanged(string propName) 
       {
           var e = PropertyChanged;
           if (e != null) 
              e(this, new PropertyChangedEventArgs(propName)); // некоторые из нас здесь используют Dispatcher, для безопасного взаимодействия с UI thread
       }
    
       public event PropertyChangedEventHandler PropertyChanged;
    }
    


    За исключением аттрибута Magic, все остальное выглядит более-менее в порядке. Разберемся с MagicAttribute, который описан в той же сборке, что и наш класс MyViewModel.

    class MagicAttribute: Attribute {}
    


    Одна строчка, спросите вы? Именно так. Достаточно определить в вашей сборке атрибут с именем MagicAttribute, применить его к базовому или любому классу, реализующему INPC. В этом случае все public свойства этих классов и их наследников станут INPC-совместимыми. Можно применять напрямую к свойствам вашего INPC класса, тогда только эти свойства станут INPC-совместимыми.

    А добавив такой аттрибут:
    class NoMagicAttribute: Attribute {}
    

    можно исключать классы и свойства из магической реализации INPC.

    Переживать за лишнии килобайты кода не стоит, после компиляции вашей сборки, от этих аттрибутов не останется и следа, убедиться в чем вам поможет Reflector.

    Теперь немного о том, как это работает.

    • Все происходит на этапе компиляции. Точнее после компиляции, но до подписи сборки. Т.е. в рантайме получаем максимальную производительность (в большинстве случаев даже быстрее рукописного кода).
    • KindOfMagic буквально дописывает в setter то, что нам самим лениво писать, тем самым сокращая нагрузку на пальцы, Resharper, редактор кода и нервы.
    • KindOfMagic делает свойства INPC-совместимыми и только это. Причем делает это оптимально с точки зрения IL, быстро и прозрачно. Сопутствующие PDB файлы тоже трансформируются, поэтому проблем с отладкой «заколдованных» свойств нет.
    • KindOfMagic вызывает RaisePropertyChanged только тогда, когда свойство действительно изменилось. Код проверки old-new генерируется в зависимости от типа свойства. Поддерживаются любый типы, включая Nullable<T>.
    • KindOfMagic поддерживает как Silverlight, так и .NET проекты.
    • KindOfMagic использует Mono.Cecil для инъекции кода. Спасибо Мигелю и К.


    Ну и теперь, встречаем победителя:



    Здесь можно скачать KindOfMagic, а здесь лежит тестовый проектик для сомневающихся. Результаты получены под Win7x64, Core2 Quad @ 2.4GHz.

    UPDATE 1
    Честно говоря, такого разгромного результата я не ожидал, полученный IL не особо сильно-то и отличается.
    При ближайшем рассмотрении был найден баг, баг успешно пофикшен. Результат KindOfMagic сравнялся с рукописным кодом, как и ожидалось.

    Настоящих чудес не бывает, бывает что-то типо :)
    Поделиться публикацией

    Комментарии 43

      +3
      Спасибо, очень интересное решение, как минимум в плане отсутствия написания одинакового кода. Правда к подобным ухищрение приводит печальное отсутствие макросов образца Nemerle.
        0
        Nemerle хорош, но хотелось бы таких же наворотов в c#
          0
          А чем не устраивает Nemerle?
            0
            Экосистемой
              0
              Он хорош. Честное слово, очень хорош. Если бы вместо F# был бы он — то я бы с удовольствием писал на нем. А так — никто не даст в коммерческом проекте использовать малоизвестный не поддерживаемый майкрософтом язык.
          –2
          Mono. Fuck yea.
            +3
            Mono.Cecil это просто библиотека, которая так называется. Она к mono не привязана.
              +4
              Не поверите, но я знал об этом.
            +4
            Здорово, но атрибут лучше переименовать во что-нибудь более адекватное, например: NotifyOnChanged.
              0
              Не первый раз слышу такое предложение, для этого даже параметризировал названия атрибутов в .targets файле.
                0
                Попутный вопрос.
                Я немного далек от INPC, но RaisePropertyChanged — это устоявшаяся форма вызова события? Или все-таки по гайдлайнам virtual protected метод для вызова события должен наименоваться OnPropertyChanged и уже иметь в качестве аргумента PropertyChangedEventArgs?
                  +1
                  Raise обычно. и логично. On- как раз для virtual методов и это уже совсем другой подход, основанный не на делегатах, а на перегрузке. привык искать Raise-, но иногда попадаются On-

                  видимо, по инерции бывших VB-кодеров
                    +1
                    В идеале — сделать поддержку сразу нескольких «стилей»: RaisePropertyChanged, OnPropertyChanged, NotifyPropertyChanged, InvokePropertyChanged.
                      +3
                      и еще, HeyPropertyToUzheChanged ))
                        +3
                        Нашел интересную ссылку ровно по этому поводу.

                        Видимо, случай похожий на ID. Но по гайдам на мсдн все-таки OnEventName.
                          0
                          Этот метод является небольшим исключением.

                          Во-первых, он не совсем обязательно должен быть protected.

                          Во-вторых, если параметром будет PropertyChangedEventArgs — это просто праздник какой-то в каждом сеттере его создавать :)
                            0
                            Это такой шаблон, по ссылке объясняется почти каждое ключевое слово в нём.
                            +1
                            RaiseEventName — для открытых методов, вызывающих событие. OnEventName — для защищённых методов, возможно предназначенных для переопределения в наследниках.

                            Само название метода RaiseEventName говорит о том, что он предназначен для вызова извне.
                              0
                              Ну так здесь как раз метод объявлен как protected virtual, т.е. для наследников для возможного переопределения.
                              Вот такой нюанс. Да впрочем мелочи всё это =)

                    0
                    Есть еще вариант с работой через прокси объект.
                    Пробовал такой механизм на NHibernate, где и так прокси генерится (для lazy), поэтому лишнего расхода нет.
                    При этом достаточно свойства сущностей объявлять как virtual, а в генераторе прокси написать единообразную обработку вызова сеттеров.

                    Ну а про MVVM — все-таки там не только нотификация изменений. Да и этот уровень, он ведь ViewModel, модель представления, он не должен быть тяжеловесным, да и производительность не так критична, т.к. «тормозом» обычно является пользователь.
                      +5
                      Давно уже ни для кого не секрет, что DependencyProperty тормознее ручной реализации INPC.

                      Ну и установка значения прям в field будет, наверное, быстрее, чем в property. Но нам это не так интересно. Скорее всего нужно думать в случае WPF и Silverlight об установке значений в свойства, а так же о времени Binding, то есть передачи значения самому контролу, а вот тут вот ситуация меняется в другую сторону. Не даром вы говорите о множестве полей у объекта, а так же о множествах ViewModel привязанных к DataGrid.

                      По WPF есть набор статей связанных с оптимизацией на MSDN, вот одна из них касательно вашей темы Optimizing Performance: Data Binding. И там как раз DP работает шустрее CLR объекта с INotifyPropertyChanged. Но ненамного, то есть это не есть место для оптимизации. В Silverlight, думаю, все должно быть примерно так же.

                      Собственно, часть статьи про оптимизацию считаю неоправданной: не там и не то оптимизировали. А по поводу AOP — это бесспорно хороший вариант. Есть еще более простой вариант — написать свой сниппет в VS. Типа такого C# Code Snippets for properties raising PropertyChanged event.
                        0
                        Сниппеты не рефакториабельны без Решарпера, плюс ко всему — очень много букаф ;)

                        Но согласен, до KindOfMagic моим ежедневным решением были именно сниппеты.

                        Насчет, производительности data-binding — msdn-статье доверяй, но проверяй. У меня dependencyObjects ну никак быстрее не получаются, чем обычные свойства.

                        И этому есть объктивные причины:
                        1) прямой доступ без Dictionary
                        2) родная проверка на изменение

                        В чем могут dependency properties выигрывать — так это в боксинге. Происходит один раз в сеттере и геттере, а так везде как Object передается и хранится.
                          0
                          На сколько я понимаю вы тестируете просто тупо установку значений в свойства, так? Тут очевидно, что DP должен проигрывать. А вот в статье говорится о времени байдинга, как раз то, что нам очень важно в SL/WPF.
                          То есть верным тестом было бы создание для каждого свойства байдинга к какому-нибудь объекту и уже потом установка свойств, и подсчет времени.
                            0
                            Байдинг от слова байда или байдарка? ;)

                            Да, в тест-проекте тестируется только запись свойства и перезапись с другим значением. Это нужно для того, чтобы выпятить на поверхность тот функционал, производительность которого я и хочу сравнивать.

                            Мой комментарий по поводу производительности дата-байндинга относился именно к впечатлению вцелом.

                            Давайте не будем забывать, что dependency properties это костыль, придуманный MS исключительно ради Attached properties, типо Grid.Column.

                            Остальное их испотзование притянуто за уши, т.к. Xaml замечательно работает и с простыми свойствами.
                              0
                              Байдинг от слова байда или байдарка? ;)

                              Байдинг — так оно произносится, одно время тоже делали мне замечания по поводу «биндинг».

                              Да, в тест-проекте тестируется только запись свойства и перезапись с другим значением. Это нужно для того, чтобы выпятить на поверхность тот функционал, производительность которого я и хочу сравнивать.

                              В итоге вы не сравнили ничего, точнее сравнили, но впустую. DependencyObject нужно использовать только в контексте с байдингом, отдельно смысла от него ну совсем нет. Тестировать тоже с ним.

                              Давайте не будем забывать, что dependency properties это костыль, придуманный MS исключительно ради Attached properties, типо Grid.Column.

                              Это ваша додумка. Я с вами тут не согласен. Дам встречный вопрос: «Сколько значений в одно время может иметь Dependency Property?». Вот-вот, обычный CLR property такое не позволит, а в случае с анимацией, стилями — это полезная штука.

                                +1
                                Binding произносится как байндинг согласно всем моим англоязычным коллегам. Но может они ошибаются, плохо знают родной английский.

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

                                Я сравнивал запись в свойство, а также возможность реагировать на изменение этого свойства изнутри класса. Понятное дело, что реагировать на изменение свойства хотелось бы в его родном виде, поэтому везде стоит typecast. Это мой обычный сценарий. Никаких натяжек.

                                Выигрывая в транспортировке значения как object в случае композитных data-binding, мы проигрываем в использовании этого свойства в коде, т.к. typecast и извлечение значение из Dictionary по ключу инстанса DependencyProperty — это все-таки накладные расходы.

                                Еще интересно покопаться в классах CLRPropertyListener и DependencyPropertyListener, станет ясно, что логика везде одинаковая…

                                Сколько значений в одно время может иметь Dependency Property?

                                Я не умоляю достоинств DependencyProperties, просто их место в контролах, которые уже написаны за нас. В статье же речь идет об удобном варианте создания ViewModel-ей. И как мне показалось, использовать DependencyObject в этом контексте не совсем разумно. Ну если только вам необходима анимация свойств у ваших ViewModel-ей :)

                                А насчет анимации — соглашусь, не подумал. Хотя уверен, что простые свойства тоже поддаются анимации. Другое дело, что это будет уже Frame-based, а не Time-based анимация, которой MS так гордится.
                                  0
                                  Все верно, в реализации своих ViewModel лучший вариант — это INotifyPropertyChanged. В контролах DependencyProperty.

                                  Выигрывая в транспортировке значения как object в случае композитных data-binding, мы проигрываем в использовании этого свойства в коде, т.к. typecast и извлечение значение из Dictionary по ключу инстанса DependencyProperty — это все-таки накладные расходы.

                                  Еще интересно покопаться в классах CLRPropertyListener и DependencyPropertyListener, станет ясно, что логика везде одинаковая…


                                  Это накладные расходы, но вот то, что это медленнее работает — это только ваша додумка. Еще раз: ваши тесты этого не подтверждают, они только проверяют то, что если в своем приложении реализовать свойства у какого-нибудь класса как обычные CLR с интерфейсом или как DependencyProperty (которые еще раз говорю в контексте не связывания с другими dependency property не имеют смысла). Вы не реализовали инфраструктуру WPF/Silverlight у себя в тестах.

                                  И честно говоря, я не очень понимаю, зачем в этом месте что-то оптимизировать. Особенно в бизнес сценариях. Ну будет у вас DataGrid и 1000 ViewModel, а в каждом по 20 свойств, все из которых отображаете. Но не всех же 1000 моделей будут отображаться, у DataGrid есть виртуализация. 10 ms против 50 ms? Это выигрываем?
                                    +1
                                    1000 моделей нужно заполнить данными до того, как хоть одна из них будут отображаться. Фактор 13 это много.
                                    Это вместо 400ms получаем 5 секунд. 5 секунд 100% нагрузки на ядро.

                                    Неудивительно, что потом люди начинают жаловаться, что Silverlight тормозит (или садит батарейки в моем планшете) :)

                                    Давайте просто не будем писать тормозной код. Преждевременная оптимизация — это грех, согласен. Но выбор изначально тормозной технологии — 100% гарантия тормозов в будущем и существенные издержки на их устранение.

                                    Врага нужно знать в лицо :)

                                      0
                                      Я не за DependencyProperty, так как уже говорил, что считаю их полезными только для собственных контролов. Я за использование Lambda варианта. А если будет проблема с производительностью в установке значений, то всегда можно сделать доп. метод для инициализации сущностей и т.п. В общем проблемы легко решаются.
                                      0
                                      По поводу DataGrid — как у Вас получилось достичь таких малых времён (50ms)?
                                      У меня на проекте DataGrid с 30 строками и по ~10 свойств в каждой VM, даже при сортировке кликом по колонке (вне зависимости от того, встроенный сортировщик, или указан собственный) время перекомпоновки приближается к секунде.
                                        0
                                        Ну я это я чисто гипотетически :) DataGrid из тулкита не очень хорош
                          +2
                          А можно показать какой код сеттера то в итоге генерится?? И за счет чего он становится аж в 3 раза быстрее рукописного??
                            0
                            В IL я генерю более простую проверку. C# подходит к делу более основательно, что в итоге слегка медленнее.
                              +2
                              Был найден баг, сравнение строчек выполнялось только как reference.

                              Баг пофикшен, теперь все как обычно :)
                              +1
                              А отлаживать такой код в студии возможно?
                                0
                                Возможно. Но сгенерированного кода, понятное дело, не видно.
                                +1
                                Полный код теста в студию, плз. У меня особой разницы между Manual и Lambda нет.
                                  +1
                                  Отставить тревогу. Разница есть.
                                  +2
                                  > Поэтому я, как неисправимый преждевременный оптимизатор, склоняюсь именно к INPC.
                                    +1
                                    однако, если переписать так, то разница будет абсолютно приемлимые 10%:
                                     
                                           staticExpression<Func<LambdaNPC, string>> MyPropertyExpression = o => o.MyProperty;
                                            private string _MyProperty;
                                            public string MyProperty
                                            {
                                                get { return _MyProperty; }
                                                set
                                                {
                                                    if (_MyProperty == value)
                                                    {
                                                        return;
                                                    }
                                                    _MyProperty = value;
                                                    RaisePropertyChanged(MyPropertyExpression);
                                                }
                                            }
                                    
                                            void RaisePropertyChanged<T>(Expression<Func<LambdaNPC, T>> raiser)
                                            {
                                                var e = PropertyChanged;
                                                if (e != null)
                                                {
                                                    var propName = ((MemberExpression)raiser.Body).Member.Name;
                                                    e(this, new PropertyChangedEventArgs(propName));
                                                }
                                            }
                                    
                                      0
                                      За что боролись, на то и напоролись.

                                      Кто-то хотел минимум кода при максимуме производительности ;)
                                        0
                                        В данном случае производительность должна быть не «о ужас, ужас» — этого уже достаточно, т.к. reflection при байндинге более узкое место по производительности. Конечно, INPC — это абсолютно точно реализация аспекта императивным программированием. Но пока, увы ни C#, ни VS, как платформа разработки, недружественны к AOP.
                                      0
                                      .net 4.0? :(
                                      насколько сильны завязки? хочу попробовать портировать под 2.0 и интересно, достаточно ли переработать linq, или завязано глубже?

                                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                      Самое читаемое