Pull to refresh

Не стреляйте себе в ногу, используя LINQ

Reading time 5 min
Views 32K
В статье я описал несколько примеров неочевидных моментов при использовании LINQ to SQL. Если вы гуру .NET, вам, возможно, покажется это скучным, остальным — добро пожаловать!
Начнем с такого примера. Допустим, у нас есть сущность «тип действия». У типа действия есть human-readable имя и системное имя — некий уникальный идентификатор, по которому с объектами этой сущности мы сможем работать из кода. Вот такая структура в виде объектов в коде:

class ActionType
{
	public int id;
	public string systemname;
	public string name;
}

var ActionTypes = new ActionType[] {
	new ActionType {
		id = 1,
		systemname = "Registration",
		name = "Регистрация"
	},
	new ActionType {
		id = 2,
		systemname = "LogOn",
		name = "Вход на сайт"
	},
	new ActionType {
		id = 3,
		systemname = null,
		name = "Некоторый тип действия без системного имени"
	}
};

Для такой же структуры с аналогичными данными создана таблица в БД и вспомогательные объекты для использования LINQ to SQL. Допустим, нам необходимо выяснить, существует ли у нас тип действия с системным именем NotExistingActionType. Вопрос в том, что будет выведено на экран после выполнения этих инструкций:

var resultForObjects = ActionTypes.All(actionType => actionType.systemname != "NotExistingActionType");
var context = new LinqForHabr.DataClasses1DataContext();
var resultForLTS = context.ActionTypes.All(actionType => actionType.SystemName != "NotExistingActionType");

Console.WriteLine("Result for objects: " + resultForObjects + "\nResult for Linq to sql: " + resultForLTS);
Console.ReadLine();

Ответ в данном случае на первый взгляд странный. Результатом работы приложения будет:
Result for objects: True
Result for LINQ to sql: False
Почему «один и тот же метод» возвращает разные значения для одних и тех же данных? Всё дело в том, что это совсем не один и тот же метод, а разные методы с одинаковыми названиями. Первый итерирует по объектам в памяти, второй же преобразуется в SQL запрос, который будет выполнен на сервере и вернет нам другой результат. Результаты отличаются из-за наличия среди наших типов действий одного с неопределенным системным именем. И в данном моменте проявляются специфические различия двух сред выполнения: для .NET выражение null != objRef — истина (если конечно objRef не null), а следовательно и значение выражения «системные имена всех типов действий не равны NotExistingActionType» в нашей ситуации будет истинным.
Но LINQ to SQL выражения преобразуются в SQL и выполняются на сервере, и в SQL сравнение с NULL работает по-другому. Значения выражений NULL == Something, NULL != Something и даже NULL == NULL всегда будут ложными, таков стандарт, поэтому выражение «системные имена всех типов действий не равны NotExistingActionType» и не будет истинной, так как NULL != 'NotExistingActionType' — ложь.

Теперь рассмотрим другой пример. Допустим, у нас есть пользователи с их текущим балансом:

class User
{
	public int id;
	public int balance;
	public string name;
}

var users = new User[] { 
	new User {                     
		id = 1, 
		name = "Василий",
		balance = 0
	},
	new User {                     
		id = 2, 
		name = "Георгий",
		balance = 0
	}
};

Вопрос в том, что должна возвращать сумма по пустому набору элементов. Для меня, например, очевидным значением является 0, но тут тоже не всё так просто. Выполним что-то типа такого:

var resultForObjects = users.Where(user => user.id < 0).Sum(user => user.balance);
var context = new LinqForHabr.DataClasses1DataContext();
var resultForLTS = context.Users.Where(user => user.Id < 0).Sum(user => user.Balance);

Console.WriteLine("Result for objects: " + resultForObjects + "\nResult for Linq to sql: " + resultForLTS);
Console.WriteLine(context.ActionTypes.First().Name);
Console.ReadLine();

Результат снова будет необычным. Вообще говоря, выполнить эти инструкции в таком виде мы не сможем, так как при выполнении возникнет исключение:
System.InvalidOperationException: «Значение NULL не может быть присвоено члену, который является типом System.Int32, не допускающим значения NULL.»

Причины происходящего снова в трансляции наших вызовов в SQL. Для обычного IEnumerable экстеншн Sum возвращает 0 для пустого набора, в чем легко убедиться, не вычисляя resultForLTS (ну или в конце концов прочитав это вот тут msdn.microsoft.com/ru-ru/library/bb549046). Однако СУБД вычисляет сумму пустого набора как NULL (правильно это или нет — вопрос довольно холиварный, но сейчас это просто факт), и LINQ, пытаясь вернуть null вместо целого числа, немедленно терпит фиаско. Починить это место крайне просто, но необходимо держать ухо востро:

var resultForLTS = context.Users.Where(user => user.Id < 0).Sum(user => (int?)user.Balance) ?? 0;

Тут возвращаемое значение функции Sum становится не int, а nullable int (этого можно добиться и явным указанием типа generic'а), что дает возможность LINQ вернуть null, а оператор ?? превратит этот null в 0.

Ну и последний пример. Удивительно, но трансляция в SQL дает нам немного синтаксического сахара. Рассмотрим вот какой пример. Добавим объект Location, и у пользователей теперь будет ссылка на их город:

class User
{
	public int id;
	public int balance;
	public string name;
	public Location location;
}

class Location
{
	public int id;
	public string Name;
}

Не будем создавать никаких объектов Location и изменять пользователей, интерес представляет вот такой код:

var resultForObjects = users.Select(user => 
	user.location == null ? 
		"Локация не указана" : user.location.Name == null ? 
			"Локация не указана" : user.location.Name)
	.First();
var context = new LinqForHabr.DataClasses1DataContext();
var resultForLTS = context.Users.Select(user => 
	user.Location == null ? 
		"Локация не указана" : user.Location.name == null ? 
			"Локация не указана" : user.Location.name)
	 .First();

В обоих случаях результатом будет строка «Локация не указана», так как она действительно не указана, но что будет, если написать вот так:

var resultForLTS = context.Users.Select(user => user.Location.name ?? "Локация не указана");

Вы можете подумать, что так это не будет работать, так как тут присутствует явный NullReferenceException (ни один пользователь не имеет объекта Location априори, мы их не создавали, в базу не записывали), но не забываем, что этот код не будет запущен в окружении .NET, а будет транслирован в SQL и запущен СУБД. На самом деле, запрос, который получится из этого кода, будет выглядеть так (LINQPad в помощь):

SELECT COALESCE([t1].[name],@p0) AS [value]
FROM [Users] AS [t0]
LEFT OUTER JOIN [Locations] AS [t1] ON [t1].[Id] = [t0].[LocationId]

Этот «трюк» позволяет нам не писать дикое количество тернарных операторов в запросах на LINQ.

Вывод:


Когда мы пишем код, мы постоянно полагаемся на функции более низкого уровня и считаем, что эти функции работают верно. Прекрасно, что есть такой способ сокращения сложности, но нужно всегда отдавать себе отчет в том, достаточно хорошо ли мы понимаем, что сделает та или иная функция, которую мы используем. А для этого — RTFM!
Tags:
Hubs:
+19
Comments 11
Comments Comments 11

Articles