Понимание ленивой оптимизации загрузки в C#

Прочитав немного о том, как yield, foreach, linq отложено выполнение и итераторы работают в C#. Я решил попробовать оптимизировать механизм валидации на основе атрибутов в небольшом проекте. Результат:

private IEnumerable<string> GetPropertyErrors(PropertyInfo property)
{
    // where Entity is the current object instance
    string propertyValue = property.GetValue(Entity)?.ToString();

    foreach (var attribute in property.GetCustomAttributes().OfType<ValidationAttribute>())
    {
        if (!attribute.IsValid(propertyValue))
        {
            yield return $"Error: {property.Name} {attribute.ErrorMessage}";
        }
    }
}

// inside another method
foreach(string error in GetPropertyErrors(property))
{
    // Some display/insert log operation
}

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

Итак, мой вопрос... Это оптимальное или хорошее использование ленивого механика погрузки? или я что-то упускаю и просто теряю тонны ресурсов.

ПРИМЕЧАНИЕ. Само намерение кода не имеет значения, меня беспокоит использование в нем ленивой загрузки.

2 ответа

Решение

Ленивая загрузка не является чем-то специфичным для C# или Entity Framework. Это общий шаблон, который позволяет отложить загрузку данных. Отсрочка означает немедленную загрузку. Некоторые образцы, когда вам это нужно:

  • Загрузка изображений в (Word) документ. Документ может быть большим и содержать тысячи изображений. Если вы загрузите все их при открытии документа, это может занять много времени. Никто не хочет сидеть и смотреть 30 секунд на загрузку документа. Такой же подход используется в веб-браузерах - ресурсы не отправляются с телом страницы. Браузер откладывает загрузку ресурсов.
  • Загрузка графиков объектов. Это могут быть объекты из базы данных, объекты файловой системы и т. Д. Загрузка полного графика может быть равна загрузке всего содержимого базы данных в память. Как долго это займет? Это эффективно? Нет. Если вы создаете какой-либо проводник файловой системы, загрузите ли вы информацию о каждом файле в системе, прежде чем начать его использовать? Это намного быстрее, если вы будете загружать информацию только о текущем каталоге (и, возможно, это прямые дочерние элементы).

Ленивая загрузка не всегда означает задержку загрузки до тех пор, пока вам действительно не понадобятся данные. Загрузка может произойти в фоновом потоке, прежде чем вам действительно понадобятся эти данные. Например, вы никогда не сможете прокрутить страницу вниз, чтобы увидеть изображение нижнего колонтитула. Ленивая загрузка означает только отсрочку. И C# перечислители могут помочь вам в этом. Рассмотрим получение списка файлов в каталоге:

string[] files = Directory.GetFiles("D:");
IEnumerable<string> filesEnumerator = Directory.EnumerateFiles("D:");

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

Второй подход использует перечислитель - он возвращает файлы один за другим, когда вы запрашиваете следующее имя файла. Это означает, что перечислитель возвращается сразу, не получая все файлы и не сохраняя их в какой-либо коллекции. И вы можете обрабатывать файлы один за другим, когда вам это нужно. Здесь получение файлов отсрочено.

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

public IEnumerable<string> EnumerateFiles(string path)
{
    foreach(string file in Directory.GetFiles(path))
        yield return file;
}

Здесь вы используете GetFiles метод, который заполняет массив имен файлов перед их возвратом. Так что сдача файлов один за другим не дает вам никаких преимуществ в скорости.

Кстати, в вашем случае у вас точно такая же проблема - GetCustomAttributes внутреннее расширение Attribute.GetCustomAttributes метод, который возвращает массив атрибутов. Так что вы не будете сокращать время получения первого результата.

Это не совсем то, как термин "отложенная загрузка" обычно используется в.NET. "Ленивая загрузка" чаще всего используется для чего-то вроде:

public SomeType SomeValue
{
  get
  {
    if (_backingField = null)
      _backingField = RelativelyLengthyCalculationOrRetrieval();
    return _backingField;
  }
}

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

Здесь мы отложили исполнение. Это похоже, но не совсем то же самое. Когда вы звоните GetPropertyErrors(property) вместо получения коллекции всех ошибок вы получаете объект, который может найти эти ошибки при запросе их.

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

Это всегда будет уменьшать использование памяти, потому что это не тратит память на коллекцию.

Это также сэкономит время, потому что на создание коллекции не тратится время.

Однако, если вам нужно получить к нему доступ более одного раза, тогда, пока коллекция будет по-прежнему иметь те же результаты, ей придется вычислять их все снова (в отличие от отложенной загрузки, которая загружает свои результаты и сохраняет их для последующего повторного использования).

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

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

Если вам иногда захочется получить один и тот же набор результатов, вы можете принять решение о том, кэшировать или нет, вызывающей стороне с помощью одного вызова GetPropertyErrors() и действуя на результаты напрямую, но повторное использование вызова ToList() на этом, а затем действуя неоднократно в этом списке.

Таким образом, подход не посылать список является более гибким, позволяя вызывающему коду решать, какой подход является более эффективным для его конкретного использования.

Вы также можете комбинировать это с отложенной загрузкой:

private IEnumerable<string> LazyLoadedEnumerator()
{
  if (_store == null)
    return StoringCalculatingEnumerator();
  return _store;
}

private IEnumerable<string> StoringCalculatingEnumerator()
{
  List<string> store = new List<string>();
  foreach(string str in SomethingThatCalculatesTheseStrings())
  {
    yield return str;
    store.Add(str);
  }
  _store = store;
}

Эта комбинация редко полезна на практике.

Как правило, начните с отложенной оценки в качестве обычного подхода и решите далее в цепочке вызовов, сохранять результаты или нет. Исключением является то, что вы можете знать размер результатов до того, как начнете (вы не можете здесь узнать, потому что вы не знаете, будет ли элемент добавлен или нет, пока вы не изучите свойство). В этом случае существует возможность улучшения производительности только в том, как вы создаете этот список, потому что вы можете установить его емкость заранее. Это, однако, микрооптимизация, которая применима только в том случае, если вы также знаете, что вам всегда захочется поработать со списком, и при этом не так много сэкономить в общей схеме вещей.

Другие вопросы по тегам