Как стать автором
Обновить
743.9
OTUS
Цифровые навыки от ведущих экспертов

7 опасных ошибок, которые легко совершить в С#/.NET

Время на прочтение8 мин
Количество просмотров11K
Автор оригинала: Chris
Перевод статьи подготовлен в преддверии старта курса «C# ASP.NET Core разработчик».




C# — великолепный язык, и .NET Framework также очень хорош. Строгая типизация в C# способствует уменьшению количества ошибок, которые вы способны спровоцировать, по сравнению с другими языками. Плюс его общая интуитивная конструкция тоже очень помогает, по сравнению с чем-то наподобие JavaScript (где true это false). Тем не менее, в каждом языке есть свои грабли, на которые легко наступить, наряду с ошибочными представлениями об ожидаемом поведении языка и инфраструктуры. Я попытаюсь подробно описать некоторые из этих ошибок.

1. Не понимать отложенное (ленивое) выполнение


Я полагаю, что опытные разработчики знают об этом механизме .NET, но он может застать врасплох менее осведомленных коллег. В двух словах, методы/операторы, которые возвращают IEnumerable<T> и используют yield для возвращения каждого результата, не выполняются в той строке кода, которая фактически их вызывает, — они выполняются когда к результирующей коллекции обращаются каким-либо образом*. Обратите внимание, что большинство LINQ-выражений в конечном итоге возвращают свои результаты c yield.

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

[TestMethod]
[ExpectedException(typeof(ArgumentNullException))]
public void Ensure_Null_Exception_Is_Thrown()
{
   var result = RepeatString5Times(null);
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void Ensure_Invalid_Operation_Exception_Is_Thrown()
{
   var result = RepeatString5Times("test");
   var firstItem = result.First();
}
private IEnumerable<string> RepeatString5Times(string toRepeat)
{
   if (toRepeat == null)
       throw new ArgumentNullException(nameof(toRepeat));
   for (int i = 0; i < 5; i++)
   {   
       if (i == 3)
            throw new InvalidOperationException("3 is a horrible number");
       yield return $"{toRepeat} - {i}";
   }
}

Оба эти теста не будут пройдены. Первый тест будет не пройден, потому что результат нигде не используется, поэтому тело метода никогда не будет выполнено. Второй тест будет не пройден по другой, немного более нетривиальной причине. Теперь же мы получаем первый результат вызова нашего метода, чтобы гарантировать, что метод действительно будет выполнен. Однако механизм отложенного выполнения выйдет из метода сразу как сможет — в данном случае мы использовали только первый элемент, поэтому, как только мы передадим первую итерацию, метод останавливает свое выполнение (поэтому i == 3 никогда не будет true).

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

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


Это особенно неприятно, и я уверен, что у меня где-то есть код, который полагается на это предположение. Когда вы добавляете элементы в список List<T>, они сохраняются в том же порядке, в котором вы их добавляете — логично. Иногда вам нужно иметь другой объект, связанный с элементом в списке, и очевидным решением является использование словаря Dictionary<TKey,TValue>, который позволяет указать для ключа связанное значение.

Затем вы можете перебирать словарь, используя foreach, и в большинстве случаев он будет вести себя вполне ожидаемо — вы будете получать доступ к элементам в том же порядке, в каком они были добавлены в словарь. Тем не менее, это поведение не определено — т.е. это счастливое совпадение, а не то, на что вы можете полагаться и всегда ожидать. Это объясняется в документации Microsoft, но я думаю, что немногие люди внимательно изучали эту страницу.

Чтобы проиллюстрировать это, в приведенном ниже примере выходные данные будут следующими:

third
second


var dict = new Dictionary<string, object>();       
dict.Add("first", new object());
dict.Add("second", new object());
dict.Remove("first");
dict.Add("third", new object());
foreach (var entry in dict)
{
    Console.WriteLine(entry.Key);
}

Не верите мне? Проверьте здесь онлайн сами.

3. Не брать в расчет потокобезопасность


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

Проще говоря, многие базовые классы в библиотеке .NET не являются потокобезопасными (thread safe) — это означает, что Microsoft не дает никаких гарантий, что вы сможете использовать данный класс параллельно, используя несколько потоков. Это не было бы большой проблемой, если бы вы могли сразу обнаружить какие-либо проблемы, связанные с этим, но природа многопоточности подразумевает, что любые возникающие проблемы очень нестабильны и непредсказуемы — скорее всего, никакие два исполнения не дадут одинакового результата.

В качестве примера возьмем этот блок кода, который использует незатейливый, но не потокобезопасный, List<T>.

var items = new List<int>();
var tasks = new List<Task>();
for (int i = 0; i < 5; i++)
{
   tasks.Add(Task.Run(() => {
       for (int k = 0; k < 10000; k++)
       {
           items.Add(i);
       }
   }));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(items.Count);

Таким образом, мы добавляем числа от 0 до 4 к списку по 10000 раз каждое, что означает, что список в итоге должен содержать 50000 элементов. Должен, да? Ну, есть небольшой шанс, что в итоге так и будет — но ниже приведены результаты 5 моих разных запусков:

28191
23536
44346
40007
40476

Вы можете самостоятельно проверить это онлайн здесь.

По сути, это потому, что метод Add не является атомарным, из чего следует, что поток может прервать (interrupt) метод, что в конечном итоге может изменить размер массива, пока другой поток находится в процессе добавления, или добавить элемент с тем же индексом, что и другой поток. Пару раз мне прилетало исключение IndexOutOfRange, вероятно, потому, что размер массива изменялся во время добавления к нему. Так что же нам здесь делать? Мы можем использовать ключевое слово lock, чтобы гарантировать, что только один поток может добавлять элемент (Add) в список в один момент времени, но это может заметно повлиять на производительность. Microsoft, будучи любезными людьми, предоставляет несколько замечательных коллекций, которые являются потокобезопасными и высоко оптимизированными с точки зрения производительности. Я уже публиковал статью, описывающую, как вы можете использовать их.

4. Злоупотреблять ленивой (отложенной) загрузкой в LINQ


Ленивая загрузка — это отличная фича как LINQ to SQL, так и LINQ to Entities (Entity Framework), позволяющая загружать связанные строки таблицы по мере необходимости. В одном из других моих проектов у меня есть таблица «Модули» и таблица «Результаты» с отношением «один ко многим» (модуль может иметь много результатов).



Когда я хочу получить определенный модуль, я, конечно, не хочу, чтобы Entity Framework возвращал каждый Результат, который есть у таблицы Модулей! По этому, он достаточно умен, чтобы выполнять запрос для получения результатов только тогда, когда мне это нужно. Таким образом, приведенный ниже код будет выполнять 2 запроса — один для получения модуля, а другой для получения результатов (для каждого модуля),

using (var db = new DBEntities())
{
   var modules = db.Modules;
   foreach (var module in modules)
   {
       var moduleType = module.Results;
      //Производим операции с модулем
   }
}

Однако, что если у меня сотни модулей? Это означает, что отдельный SQL запрос для получения записей результатов будет выполняться для каждого модуля! Очевидно, что это приведет к нагрузке на сервер и значительно замедлит работу вашего приложения. В Entity Framework ответ очень прост — вы можете указать, чтобы он включал определенный набор результатов в ваш запрос. Смотрите модифицированный код ниже, где будет выполняться только один SQL запрос, который будет включать каждый модуль и каждый результат для этого модуля (сведенный в один запрос, который Entity Framework интеллектуально отображает в вашей модели),

using (var db = new DBEntities())
{
   var modules = db.Modules.Include(b => b.Results);
   foreach (var module in modules)
   {
       var moduleType = module.Results;
      //Производим операции с модулем
   }
}

5. Не понимать то, как LINQ to SQL/Entity Frameworks транслирует запросы


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

Большинство вызовов методов не будут работать.

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

var modules = from m in db.Modules
              select m.Name.Split(':')[1];

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

Те, которые работают, могут давать неожиданные результаты...

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

int modules = db.Modules.Sum(a => a.ID);

Если у вас есть какие-либо строки в таблице модулей, оно даст вам сумму идентификаторов. Звучит правильно! Но что, если вы выполните его, используя вместо этого LINQ to Objects? Мы можем сделать это, преобразовав коллекцию модулей в список, прежде чем мы выполним наш метод Sum.

int modules = db.Modules.ToList().Sum(a => a.ID);

Шок, ужас — оно сделает абсолютно то же самое! Тем не менее, что если у вас не было строк в таблице модулей? LINQ to Objects возвращает 0, а версия Entity Framework/LINQ to SQL генерирует исключение InvalidOperationException, в котором говорится, что оно не может преобразовать “int?” в “int”… такое. Это связано с тем, что когда вы выполняете SUM в SQL для пустого набора, вместо 0 возвращается NULL — следовательно, вместо этого он пытается вернуть nullable int. Вот несколько советов, как это исправить, если вы столкнулись с такой проблемой.

Знать, когда нужно просто использовать старый добрый SQL.

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

Кроме того, если вы столкнетесь с какими-либо узкими местами в производительности, вам будет сложно их исправить, поскольку у вас нет прямого контроля над сгенерированным SQL. Либо сделайте это на SQL, либо делегируйте это администратору базы данных, если он есть у вас или вашей компании!

6. Неправильно округлять


Теперь о чем-то немного более простом, чем предыдущие пункты, но о чем я всегда забывал, и в итоге сталкивался с неприятными ошибками (и, если это связано с финансами, разъяренным фин/ген директором).

.NET Framework включает прекрасный статический метод в классе Math, именуемый Round, который принимает числовое значение и округляет его до заданного десятичного разряда. Работает идеально большую часть времени, но что делать, когда вы пытаетесь округлить 2.25 до первого десятичного разряда? Я предполагаю, что вы, вероятно, ожидаете, что он округлится до 2,3 — вот к чему мы все привыкли, верно? Что ж, на практике получится, что .NET использует округление банкира, которое округляет приведенный пример до 2.2! Это связано с тем, что банкиры округляют до ближайшего четного числа, если число находится в «средней точке». К счастью, это можно легко переопределить в методе Math.Round.

Math.Round(2.25,1, MidpointRounding.AwayFromZero)

7. Ужасный класс 'DBNull'


Это может вызвать неприятные воспоминания у некоторых — ORM скрывает эту скверну от нас, но если вы углубитесь в мир голого ADO.NET (SqlDataReader и подобоное) вы встретите DBNull.Value.

Я не на все 100% уверен в причине, по которой значения NULL из базы данных обрабатываются следующим образом (пожалуйста, прокомментируйте ниже, если знаете!), но Microsoft решил представить их специальным типом DBNull (со статическим полем Value). Я могу привести одно из преимуществ этого — вы не получите никаких неприятных NullReferenceException исключений при доступе к полю базы данных, которое имеет значение NULL. Тем не менее, вы не только должны поддерживать вторичный способ проверки значений NULL (о котором легко забыть, что может привести к серьезным ошибкам), но вы теряете любую из замечательных возможностей языка C#, которые помогают работать с null. Что может быть так же просто, как

reader.GetString(0) ?? "NULL";

что в итоге становится…

reader.GetString(0) != DBNull.Value ? reader.GetString(0) : "NULL";

Тьфу.

Примечание


Это лишь некоторые из нетривиальных “грабель”, с которыми я сталкивался в .NET — если вы знаете больше, я хотел бы услышать от вас их ниже.



ASP.NET Core: быстрый старт


Теги:
Хабы:
+17
Комментарии14

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS