[Перевод] 6 малоизвестных фич C#/.NET

Эксперт OTUS – Алексей Ягур приглашает всех желающих на Demo Day курса “Разработчик C#”.

В преддверии старта курса делимся с вами традиционным переводом.


В этой статье я расскажу вам о шести фичах .NET Framework, которые, как мне кажется, обделены вниманием многих разработчиков – ваше мнение о том, недостаточно ли они используются, может отличаться от моего, но я надеюсь, что для некоторых из вас эта статья будет полезна.

1. Stopwatch

Начать я собираюсь с того, чем мы будем пользоваться в остальных частях этой статьи, – со Stopwatch. Вполне вероятно, что в какой-то момент у вас будет причина захотеть профилировать части вашего кода, чтобы найти узкие места в производительности. Хотя существует множество пакетов для тестирования, которые вы можете использовать в своем коде (Benchmark.NET является одним из самых популярных), иногда вам просто нужно быстро что-то протестировать без лишних заморочек. Я полагаю, что большинство людей сделают что-то наподобие этого:

var start = DateTime.Now;
Thread.Sleep(2000); //Code you want to profile here
var end = DateTime.Now;
var duration = (int)(end - start).TotalMilliseconds;
Console.WriteLine($"The operation took {duration} milliseconds");

Это сработает – вам будет объявлен результат в ~2000 миллисекунд. Однако это не золотой стандарт тестирования производительности, поскольку DateTime.Now может не дать вам необходимого уровня точности – DateTime.Now обычно имеет примерную точность до 15 миллисекунд. Чтобы продемонстрировать это, рассмотрим очень надуманный пример ниже:

var sleeps = new List<int>() { 5, 10, 15, 20 };
foreach (var sleep in sleeps)
{
	var start = DateTime.Now;
	Thread.Sleep(sleep);
	var end  = DateTime.Now;
	var duration = (int)(end - start).TotalMilliseconds;
	Console.WriteLine(duration);
}

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

15
15
15
31 

Итак, мы установили, что это не очень точный метод, но что, если вам все равно? Вы, конечно, можете продолжать использовать метод бенчмаркинга через DateTime.Now, но есть гораздо более приятная альтернатива – Stopwatch. который находится в пространстве имен System.Diagnostics. Это намного удобнее, чем использовать DateTime.Now, и выражает ваши намерения гораздо лаконичнее. Он также намного точнее! Давайте изменим наш последний фрагмент кода с использованием класса Stopwatch:

var sleeps = new List<int>() { 5, 10, 15, 20 };
foreach (var sleep in sleeps)
{
    var sw = Stopwatch.StartNew();
    Thread.Sleep(sleep);
    Debug.WriteLine(sw.ElapsedMilliseconds);
}

Теперь наш результат таков (конечно, он все еще вариабельный)

6
10
15
20

Намного лучше! Учитывая простоту использования класса Stopwatch, в действительности нет никаких причин использовать «старомодный» способ.

2. Библиотека параллельных задач (TPL – Task Parallel Library)

var items = Enumerable.Range(0,100).ToList();
var sw = Stopwatch.StartNew();
foreach (var item in items)
{
	Thread.Sleep(50);
}
Console.WriteLine($"Took {sw.ElapsedMilliseconds} milliseconds..."); 

Как и следовало ожидать, этот фрагмент занимает примерно 5000 миллисекунд/5 секунд (100 * 50 = 5000). Теперь давайте посмотрим на нашу альтернативную версию с использованием TPL…

var items = Enumerable.Range(0,100).ToList();
var sw = Stopwatch.StartNew();
Parallel.ForEach(items, (item) => 
{
	Thread.Sleep(50);					 
});
Console.WriteLine($"Took {sw.ElapsedMilliseconds} milliseconds..."); 

Теперь он занимает в среднем всего 1000 миллисекунд, что на 500% меньше! Результаты будут отличаться в зависимости от вашего сетапа, но вполне вероятно, что вы увидите улучшение, аналогичное тому, что я продемонстрировал здесь. И обратите внимание, насколько прост цикл – он едва ли сложнее обычного цикла foreach.

Но… если вы работаете с не потокобезопасным  объектом внутри цикла, тогда вам придется заморочиться. Так что увы, вы не можете просто взять и заменить любой foreach, который считаете нужным! Опять же, некоторые советы по этому поводу вы можете найти в моей статье о TPL.

3. Деревья выражений 

Деревья выражений – чрезвычайно мощная фича .NET Framework, но они также являются одной из самых плохо понимаемых (неопытными программистами). Мне потребовалось много времени, чтобы полностью понять их концепцию, и я все еще далек от экспертных познаний в этом вопросе, но по сути они позволяют вам обернуть лямбда-выражения, такие как Func<T> или Action<T>, а также проанализировать само лямбда-выражение. Вероятно, лучше всего проиллюстрировать это можно с помощью примера – а в .NET Framework их предостаточно, особенно в LINQ to SQL и Entity Framework.

Метод расширения 'Where' в LINQ to Objects принимает в качестве основного параметра Func<T, int, bool> – смотрите приведенный ниже код, который я позаимствовал из Reference Source (который содержит исходный код .NET)

static IEnumerable<TSource> WhereIterator<TSource>(IEnumerable<TSource> source, Func<TSource, int, bool> predicate) 
{
     int index = -1;
     foreach (TSource element in source) 
     {
          checked { index++; }
          if (predicate(element, index)) yield return element;
     }
}

Как и следовало ожидать – оно выполняет итерацию по IEnumerable и возвращает (yields) то, что соответствует предикату. Однако очевидно, что это не будет работать в LINQ to SQL/Entity Framework – ему необходимо преобразовать ваш предикат в SQL! Так что сигнатура для версии IQueryable немного отличается …

static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source, Expression<Func<TSource, int, bool>> predicate) 
{
      return source.Provider.CreateQuery<TSource>( 
                Expression.Call(
                    null,
                    GetMethodInfo(Queryable.Where, source, predicate),
                    new Expression[] { source.Expression, Expression.Quote(predicate) }
                    ));
}

Если вы посмотрите на пугающие внутренности метода, то заметите, что функция теперь принимает Func<T, int, bool>, обернутую в Expression – это, по сути, позволяет провайдеру LINQ читать через Func, чтобы увидеть какой именно предикат был пропущен, и преобразовать его в SQL. По сути, Expressions позволяют вам проверять ваш код во время выполнения.

Давайте рассмотрим что-нибудь попроще – представьте, что у вас есть приведенный ниже код, который добавляет настройки в словарь (мы использовали это в реальном коде)

var settings = new List<Setting>();
settings.Add(new Setting("EnableBugs",Settings.EnableBugs));
settings.Add(new Setting("EnableFluxCapacitor",Settings.EnableFluxCapacitor));

Надеюсь, здесь несложно заметить опасность/повторение – имя параметра в словаре представляет собой строку, и опечатка здесь может вызвать проблемы. К тому же, это утомительно! Если мы создадим новый метод, который принимает Expression<Func<T>> (по сути, принимает лямбда-выражение, которое возвращает что-то), мы получим фактическое имя переданной переменной!

private Setting GetSetting<T>(Expression<Func<T>> expr)
{
	var me = expr.Body as MemberExpression;
	if (me == null)
             throw new ArgumentException("Invalid expression. It should be MemberExpression");

        var func = expr.Compile(); //This converts our expression back to a Func
	var value = func(); //Run the func to get the setting value
	return new Setting(me.Member.Name,value);
}

Мы можем назвать это следующим образом…

var settings = new List<Setting>();
settings.Add(GetSetting(() => Settings.EnableBugs));
settings.Add(GetSetting(() => Settings.EnableFluxCapacitor));	

Намного приятнее! Вы заметите, что в нашем методе GetSetting мне нужно проверить, передано ли выражение как ‘MemberExpression’ – это потому, что ничто не мешает вызывающему коду передать что-то вроде вызова метода или константы, что в этом случае не является “именем члена”.

Очевидно, я очень поверхностно раскрываю, на что способны Expressions, и надеюсь написать в будущем статью, раскрывающую эту тему подробнее

4. Атрибуты сведений о вызывающем объекте

Атрибуты сведений о вызывающем объекте были введены в .NET Framework 4.0, и, хотя они в большинстве случаев не используются, они действительно показывают свою ценность при написании кода для регистрации дебажной информации. Представим, что у вас есть довольно грубая функция логирования, как показано ниже:

public static void Log(string text)
{
   using (var writer = File.AppendText("log.txt"))
   {
       writer.WriteLine(text);
   }
}

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

public static void Log(string text)
{
   using (var writer = File.AppendText("log.txt"))
   {
       writer.WriteLine($"{text} - {new StackTrace().GetFrame(1).GetMethod().Name});
   }
}

Это работает – он напечатает «Main», если я вызову его из своего метода Main. Однако это медленно и неэффективно, поскольку вы по сути захватываете стектрейс так, как если бы возникло исключение. .NET Framework 4.0 представляет вышеупомянутые «атрибуты сведений о вызывающем объекте», которые позволяют Framework автоматически сообщать вашему методу информацию о том, что его вызывает, в частности, путь к файлу, имя метода/свойства и номер строки. По сути, вы используете их, позволяя вашему методу принимать необязательные строковые параметры, которые вы помечаете атрибутом. Ниже я использую 3 доступных атрибута.

public static void Log(string text,
[CallerMemberName] string memberName = "",
[CallerFilePath] string sourceFilePath = "",
[CallerLineNumber] int sourceLineNumber = 0)
{
	Console.WriteLine($"{text} - {sourceFilePath}/{memberName} (line {sourceLineNumber})");	
}

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

5. Класс ‘Path’

Этот может быть немного более известной фичей, но я все еще наблюдаю вживую, как разработчики делают такие вещи, как получение расширения файла по имени файла вручную, при живом встроенном классе Path и наличии проверенных и надежных методов, которые сделают это за вас. Этот класс находится в пространстве имен System.IO и содержит множество полезных методов, которые сокращают объем стандартного кода, который вам необходимо написать. Многие из вас знакомы с такими методами, как Path.GetFileName и Path.GetExtension (которые работают именно так, как и следовало ожидать из названия), но я упомяну несколько более неизвестных ниже

Path.Combine

Этот метод берет 2,3 или 4 пути и объединяем их в один. Обычно люди делают это, чтобы добавить имя файла к пути к каталогу, например, directoryPath + «» + filename . Проблема в том, что вы делаете предположение, что именно символ ” является разделителем каталогов в системе, в которой работает ваше приложение, – что, если приложение работает в Unix, который использует косую черту ('/') в качестве разделителя каталогов? Это становится все более серьезной проблемой, поскольку .NET Core позволяет запускать приложения .NET на достаточно большем количестве платформ. Path.Combine будет использовать разделитель каталогов, применимый к целевой операционной системе, а также обработает ситуацию с избыточными разделителями, т.е. если вы добавите каталог с '' в конце к имени файла с '' в начале, Path.Combine вырежет один ''. Вы можете найти больше причин, по которым вам следует использовать Path.Combine здесь

Path.GetTempFileName

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

Хотя вы можете написать код для управления этим занятием самостоятельно, это будет утомительно и потенциально багоопасно. Представляю вам безумно полезный метод Path.GetTempFileName в классе Path – он не принимает никаких параметров, но создает пустой файл во временном каталоге, определяемом пользователем, и возвращает вам полный путь для его использования. Поскольку он находится во временном каталоге пользователей, Windows автоматически сохранит его, и вам не нужно будет беспокоиться о засорении системы избыточными файлами. Читайте эту статью для получения большей информации об этом методе, а также о связанным с ним пути 'GetTempPath'.

Path.GetInvalidPathChars / Path.GetInvalidFileNameChars

Path.GetInvalidPathChars и его брат Path.GetInvalidFileNameChars, возвращает массив всех символов, которые недопустимы в качестве текущих путей/имен файлов в текущих путях/именах файлов. Я видел так много кода, который вручную удаляет некоторые из наиболее распространенных недопустимых символов, таких как кавычки, но не удаляет любые другие недопустимые символы, что является катастрофой, ожидающей своего часа. И в духе кроссплатформенной совместимости неверно предполагать, что то, что недопустимо в одной системе, будет недопустимо в другой. Моя единственная критика этих методов заключается в том, что они не обеспечивают способа проверки, содержит ли путь какой-либо из этих символов, что обычно требует написания нижеприведенных шаблонных методов.

public static bool HasInvalidPathChars(string path)
{
	if (path  == null)
		throw new ArgumentNullException(nameof(path));
	
	return path.IndexOfAny(Path.GetInvalidPathChars()) >= 0;
}
	
public static bool HasInvalidFileNameChars(string fileName)
{
	if (fileName == null)
		throw new ArgumentNullException(nameof(fileName));
	
	return fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0;
}

6. StringBuilder

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

Представляю вам прекрасно названный класс StringBuilder, который позволяет выполнять конкатенацию с минимальными издержками производительности. Как это сделать? Довольно просто – на высоком уровне он сохраняет список каждого добавляемого вами символа и строит вашу строку только тогда, когда она вам действительно нужна. Для демонстрации того, как его использовать, а также о преимуществах производительности, смотрите приведенный ниже код, который тестирует оба из них (с использованием полезного класса Stopwatch, о котором я упоминал ранее)

var sw = Stopwatch.StartNew();
string test = "";
for (int i = 0; i < 10000; i++)
{
	test += $"test{i}{Environment.NewLine}";	
}
Console.WriteLine($"Took {sw.ElapsedMilliseconds} milliseconds to concatenate strings using string concatenation");
		
sw = Stopwatch.StartNew();
var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
	sb.Append($"test{i}");
	sb.AppendLine();
}
Console.WriteLine($"Took {sw.ElapsedMilliseconds} milliseconds to concatenate strings using StringBuilder");

Результаты этого бенчмарка приведены ниже:

Took 785 milliseconds to concatenate strings using the string concatenation
Took 3 milliseconds to concatenate strings using StringBuilder 

Ого – это более чем в 250 раз быстрее! И это только для 10000 конкатенаций – если вы создаете большой файл CSV вручную (что в любом случае является плохой практикой, но давайте не будем пока об этом беспокоиться), ваши значения могут быть больше. Если вы объединяете только пару строк, вероятно, будет нормально использовать concatenate без использования StringBuilder, но, честно говоря, мне нравится иметь привычку всегда использовать его – стоимость обновления StringBuilder, условно говоря, не такая уж и большая.

В моем примере я использую sb.Append, за которым следует sb.AppendLine – вы можете просто вызвать sb.AppendLine, передав свой текст, который вы хотите добавить, и он добавит новую строку в конце. Я хотел включить Append и AppendLine, чтобы было немного понятнее.

Заключение

Я полагаю, что любому опытному профессионалу большая часть вышеперечисленного будет знакома. Лично мне потребовались годы, чтобы узнать обо всем, что здесь написано, поэтому я надеюсь, что эта статья пригодится некоторым из менее опытных разработчиков и поможет избежать потери драгоценного времени на написание шаблонного и потенциально ошибочного кода, который Microsoft уже любезно написала за нас!

Я хотел бы услышать мнение всех, кто использует другие недооцененные фичи в .NET/C #, поэтому, пожалуйста, прокомментируйте эту статью, если вам есть, что рассказать!

Записаться на Demo Day курса “Разработчик C#”

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

Кстати, о “красивой упаковке” онлайн-сертификатов мы рассказываем в этой статье.

ЗАБРАТЬ СКИДКУ

Let’s block ads! (Why?)

Read More

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *