« Все записи

C#/.NET: последние пять маленьких чудес, которые делают код лучше (часть 3 из 3)

Третья и последняя часть основной трилогии "Маленькие чудеса C#" Джеймса Майкла Харе.

На этой неделе последняя часть моей серии «Маленьких чудес» (первая и вторая части здесь и здесь). Это те маленькие советы и рекомендации из .NET (и в частности C#), которые могут показаться не таким уж важными, но могут помочь сделать ваш код более кратким, обслуживаемым или производительным.

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

1. Неявная типизация

Прежде всего, давайте внесем ясность для тех, кто находится в растерянности: неявная типизация — не динамическая типизация. Любой идентификатор, неявно типизированный с ключевым словом var по прежнему является строго типизированым. Разница лишь в том, что его тип вытекает из выражения присваивания.

Многие думают, что неявная типизация какая-то «ленивая» или хуже явной. Я должен признать, что когда я впервые увидел ее, я чувствовал то же самое, но потом, подумав о C++ с его STL итераторами, когда вы набирали такие вещи, как:

for (list<int>::const_iterator it = myList.begin(); it != myList.end(); ++it)
{
    // ...
}

я начал понимать, насколько неявная итерация экономит время и повышает читаемость (те из вас, кто программирует на C++, будут рады узнать, что теперь у вас есть неявная типизация с возрождением ключевого слова auto).

Например, сколько раз вы писали подобный код:

// все довольно очевидно
ActiveOrdersDataAccessObject obj = new ActiveOrdersDataAccessObject();
 
// все еще очевидно, но насколько больше нужно набирать
Dictionary<string,List<Product>> productsByCategory = new Dictionary<string,List<Product>>();

Явная типизация obj или productsByCategory действительно что-то дает вам? Разве не очевидно, что это те же типы, которые мы присваиваем в той же инструкции? Ведь намного чище будет просто объявить:

// лучше!
var obj = new ActiveOrdersDataAccessObject();

// О, еще более лучше!
var productsByCategory = new Dictionary<string,List<Product>>();

И пусть ключевое слово var сделает всю работу за вас. C# разберет выражение присваивания, определит, какой это тип, а затем выполнит присваивание.

Очевидно, что есть ситуации, когда вы хотите чтобы ваша переменная была супер-классом или интерфейсом, и здесь явная типизация — это то, что вам нужно, но не уклоняйтесь от var из-за того, что кто-то думает, что это для лентяев. Неявной типизацией можно злоупотреблять, как любым хорошим инструментом, но она может действительно улучшить читабельность вашего кода.

На самом деле, она действительно улучшает читабельность с общими типами и с выражениями LINQ. Например, сравните это:

// неявная типизация
var results1 = from p in products where p.Value > 100 group p by p.Category;
 
// явная типизация
IEnumerable<IGrouping<string, Product>> results2 = from p in products where p.Value > 100 group p by p.Category;

Всякий раз, когда вы начинаете интенсивно использовать общие типы, коллекции и, особенно, выражения LINQ, вы можете рассчитывать на var, который будет делать код более читабельным, и выяснять за вас тип выражения.

2. Методы расширения LINQ

Сколько раз вы писали свою собственную сортировку, поиск, группировку или любой другой подобный стандартный алгоритм? Сколько часов вы провели за написанием юнит-тестов для этих методов? И, наконец, сколько раз вы допускали тривиальные баги в каждом из этих простых алгоритмов из-за опечатки?

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

Хотите сортировку? Используйте OrderBy(). Хотите фильтр по условию? Используйте Where(). Хотите выбрать подмножество свойств? Используйте Select (). Хотите группировку по свойству? Используйте GroupBy(). Это всего лишь маленький пример из множества методов расширения, которые были добавлены с помощью LINQ, и они полностью протестированы и настроены!

Например, предположим, у вас есть список продуктов List<Product>, в котором Product, помимо всего прочего, имеет свойства Value (цена) и Category (категория). И, скажем, вы хотите получить список дорогих продуктов (> $100) по категориям. Ну, если вы хотите сделать это методами старой школы, то можно написать так:

var results = new Dictionary<string, List<Product>>();
 
foreach (var p in products)
{
    if (p.Value > 100)
    {
        List<Product> productsByGroup;
        if (!results.TryGetValue(p.Category, out productsByGroup))
        {
            productsByGroup = new List<Product>();
            results.Add(p.Category, productsByGroup);
        }
 
        productsByGroup.Add(p);
    }
}

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

var results = products
    .Where(p => p.Value > 100)
    .GroupBy(p => p.Category);

Или, если хотите, как выражение LINQ для объектов:

var results = from p in products where p.Value > 100 group p by p.Category;

Алгоритмы уже написаны для вас и протестированы в придачу! Зачем переписывать код с риском ошибок? Да, вам придется изучить лямбда-выражения или язык запросов LINQ, если вы пока не привыкли к ним, но как только вы это сделаете, целый мир новых мощных алгоритмов и выражений будет открыт для вас!

3. Методы расширения

Кто-то говорит, что методы расширения являются воплощением зла, кто-то считает, что это действительно здорово. Я стараюсь быть где-то посередине. Являются ли они такими крутыми или такими же мощными как LINQ? Нет, это но во многом благодаря им LINQ имеет огромную часть своей мощи.

Так почему же Microsoft дает нам методы расширения? Почему бы просто не расширить все коллекции различными Select (), Where() и другие подобные методами LINQ? Ну, ответом является то, что не все коллекции имеют одинаковые базовые классы и очень немногие из них реализуются одинаково. Фактически есть один интерфейс, который связыват большинство коллекций вместе, это IEnumerable и, как вы знаете, вы не можете добавить функциональность интерфейса напрямую.

Поэтому Microsoft добавила возможность создавать статический метод в статическом классе, который ведет себя как член класса, из которого он вызывается. Это имеет некоторые интересные последствия, в том числе, что вы можете вызвать метод расширения на null ссылке (или вы не должны это делать, это горячо обсуждается, конечно).

Скажем, вы хотите создать метод расширения, который позволяет легко превратить любой объект в строку XML. Мы можем написать:

public static class ObjectExtensions
{
    public static string ToXml(this object input, bool shouldPrettyPrint)
    {
        if (input == null) throw new ArgumentNullException("input");
 
        var xs = new XmlSerializer(input.GetType());
        
        using (var memoryStream = new MemoryStream())
        using (var xmlTextWriter = new XmlTextWriter(memoryStream, new UTF8Encoding()))
        {
            xs.Serialize(xmlTextWriter, input);
            return Encoding.UTF8.GetString(memoryStream.ToArray());
        }
    }
}

Обратите внимание, что класс, в котором мы определяем методы расширения, должен быть статическим классом, и первый параметр метода расширения имеет ключевое слово this перед его типом. Это означает, что это статический метод можно будет вызывать непосредственно из любого объекта этого типа (в данном случае объект или все что угодно).

Теперь мы легко можем написать:

// можно перобразовывать примитивы в xml:
string intXml = 5.ToXml();

// можно перобразовывать комплексные типы в xml:
string objXml = employee.ToXml();

// можно даже вызвать статический метод по вашему выбору:
objXml = ObjectExtensions.ToXml(employee);

Обратите внимание, что это всего лишь «синтаксический сахар». Вы на самом деле не предоставляете ничего особенного здесь, кроме того, что вы можете рассматривать метод расширения как член типа, который он «расширяет». Как таковые, методы расширения являются очень мощным инструментом в вашем наборе инструментов для придания коду большей читабельности, но будьте осторожны! Чрезмерное использование методов расширения может сделать код менее читаемым и может загрязнить IntelliSense.

Как эмпирическое правило я считаю, что методы расширения предпочтительны для типов, которые вы не можете расширить (чужие или sealed (запечатаные)), или для добавления глобальной функциональности для интерфейсов (как IEnumerable). Но если класс под вашим контролем, предпочительнее добавить реальный метод экземпляра вместо этого.

4. System.IO.Path

Класс .NET System.IO.Path имеет множество статических методов для работы с файлами и путями, которые должны сделать каких-либо ручные манипуляции с вашей стороны устаревшими.

Сколько раз вы пытались совместить путь и имя файла вручную и в конечном итоге накалывались на том, что вы ожидали (или не ожидали), что путь будет заканчиваться разделителем пути ("\")? Существует статический метод Path.Combine(), который может позаботиться об этом:

// Объединяет компоненты имени пути в один путь
string fullPath = Path.Combine(workingDirectory, fileName);

Или, если у вас есть полное имя файла, содержащее путь, и вы хотите получить имя файла или только путь или только расширение? Есть множество статических методов Path, которые дадут вам все что нужно! Больше нет необходимости делать поиск подстрок в строках, вы можете просто использовать это:

string fullPath = "c:\\Downloads\\output\\t0.html";
 
// возвращает "c:\"
string pathPart = Path.GetPathRoot(fullPath);
 
// возвращает "t0.html"
string filePart = Path.GetFileName(fullPath);
 
// возвращает ".html"
string extPart = Path.GetExtension(fullPath);
 
// возвращает "c:\downloads\output"
string dirPart = Path.GetDirectoryName(fullPath);

Поэтому, прежде чем идти по пути ручных манипуляций с именами файлов и путями, рассмотрите класс Path и его статические методы.

5. Обобщенные делегаты (generic delegates)

Если вы когда-либо писали или использовали класс, который имеет события, или использовали один из многих методов расширения LINQ, то, скорее всего, вы уже использовали прямо или косвенно делегаты. Делегаты — это эффективный способ создать тип, который может быть использован для описания сигнатуры метода. Метод, который будет использован и вызван, указывается позднее во время выполнения. В плане C++ это сродни использованию указателей к функциям.

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

Здесь делегаты становятся по-настоящему мощным оружием: вы можете предоставить делегат, определяющий тип метода, который может быть вызван для проверки элемента кэша (не устарел ли он), а затем, когда пользователь создает кэш, он может передать или установить делегат метода, анонимный делегат, или «лямбда» (как он желает его называть). Таким образом, нет необходимости в дочернем классе, и вы можете запечатать ваш класс для предотвращения его непреднамеренного изменения. Это делает класс более безопасным и приспособленным для повторного использования.

Так какое это имеет отношение к обобщенным делегатам? Есть три основных «типа» делегатов, которые появляются снова и снова, и вместо того, чтобы писать эти типы делегатов неоднократно .NET оказал вам большую услугу, сделав их обобщенными, так, что они могут быть использованы повторно. Это также дает дополнительное преимущество, делая код, который зависит от делегатов, более удобным для чтения, так как эти общие делегаты хорошо понятны в использовании и намерениях:

  • Action<T> — метод, который принимает аргумент типа T и возвращает void — как правило, используется для выполнения действия над аргументом.
  • Predicate<T> — метод, который принимает аргумент типа T и возвращает bool — как правило, используется для определения, соответствует ли аргумент условию.
  • Func<TResult> — метод, который возвращает результат типа TResult — как правило, используются для генерации значений.

Обратите внимание, что это базовые формы этих общих делегатов, еще есть версии каждого, которые имеют несколько параметров, главное, что остается постоянным, что Action возвращает void, Predicate возвращает bool, а Func возвращает указанный результат.

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

public sealed class CacheItem<T>
{
    public DateTime Created { get; set; }
 
    public DateTime LastAccess { get; set; }
 
    public T Value { get; set; }
}
 
public sealed class Cache<T>
{
    private ConcurrentDictionary<string, CacheItem<T>> _cache;
    private Predicate<CacheItem<T>> _expirationStrategy;
 
    public Cache(Predicate<CacheItem<T>> expirationStrategy)
    {
        // set the delegate
        _expirationStrategy = expirationStrategy;
    }
 
    // ...
 
    private void CheckForExpired()
    {
        foreach (var item in _cache)
        {
            // call the delegate
            if (_expirationStrategy(item.Value))
            {
                // remove the item...
            }
        }
    }
}

Затем можно создать кэш и использовать его без необходимости создания подкласса.

var cache = new Cache<int>(item => DateTime.Now - item.LastAccess > TimeSpan.FromSeconds(30));

На самом деле, мы можем создать столько кэшей, сколько хотим с таким количеством стратегий устаревания, как мы можем себе представить, и не нужно подклассов! Используйте обобщенные делегаты, и они реально увеличат возможности повторного использования ваших классов. На самом деле всякий раз, когда вы думаете о добавлении абстрактного метода и нуждаетесь в обеспечении функциональности через подкласс, если все, что для чего вы это делаете — это обеспечение «хуков» или «крючков», рассмотрите использование обобщенного делегата вместо этого.

Резюме

Что ж, это конец моей серии «Маленьких чудес» на данный момент. Я действительно искренне надеюсь, что вы нашли что-то полезное по крайней мере в одной из этих трех статей. Если да, то не стесняйтесь распространять это дальше!

Я оставляю вас с 3 бонусами, которые были слишком коротки, чтобы им посвятить раздел, но мне так не хотелось оставлять вас без них!

  • Вы можете использовать префикс @ перед строковыми литералами, и вам не придется использовать управляющие последовательности для путей и т.д.:
    @"C:\Downloads\Data\Files\d0.html" вместо "C:\\Downloads\\Data\\Files\\d0.html".
  • Вам не нужно создавать новый делегат для создания нового обработчика событий, используйте общий EventHandler:
    public event EventHandler<MyEventArgs> OnClick;
  • В методе Main службы Windows (Windows Service) вы можете запросить Environment.UserInteractive,
    которое сообщает, было ли приложение запущено в качестве службы или из командной строки/IDE. Вы можете запросить это логическое свойство, чтобы определить, следует ли запускать службу в обычном режиме, или же вместо этого явно вызывать OnStart() для отладки. Умное применение этой техники позволяет легко отлаживать службы Windows в среде разработки или из командной строки.

Первая часть трилогии
Вторая часть трилогии

Progg it

comments powered by Disqus