Является ли это поведение комбинации закрытия ошибкой компилятора С#?
Я изучал некоторые странные проблемы жизни объекта и столкнулся с этим очень загадочным поведением компилятора С#:
Рассмотрим следующий тестовый класс:
class Test
{
delegate Stream CreateStream();
CreateStream TestMethod( IEnumerable<string> data )
{
string file = "dummy.txt";
var hashSet = new HashSet<string>();
var count = data.Count( s => hashSet.Add( s ) );
CreateStream createStream = () => File.OpenRead( file );
return createStream;
}
}
Компилятор генерирует следующее:
internal class Test
{
public Test()
{
base..ctor();
}
private Test.CreateStream TestMethod(IEnumerable<string> data)
{
Test.<>c__DisplayClass1_0 cDisplayClass10 = new Test.<>c__DisplayClass1_0();
cDisplayClass10.file = "dummy.txt";
cDisplayClass10.hashSet = new HashSet<string>();
Enumerable.Count<string>(data, new Func<string, bool>((object) cDisplayClass10, __methodptr(<TestMethod>b__0)));
return new Test.CreateStream((object) cDisplayClass10, __methodptr(<TestMethod>b__1));
}
private delegate Stream CreateStream();
[CompilerGenerated]
private sealed class <>c__DisplayClass1_0
{
public HashSet<string> hashSet;
public string file;
public <>c__DisplayClass1_0()
{
base..ctor();
}
internal bool <TestMethod>b__0(string s)
{
return this.hashSet.Add(s);
}
internal Stream <TestMethod>b__1()
{
return (Stream) File.OpenRead(this.file);
}
}
}
Оригинальный класс содержит два lambdas: s => hashSet.Add( s )
и () => File.OpenRead( file )
. Первая закрывается по локальной переменной hashSet
, вторая замыкает локальную переменную file
. Однако компилятор генерирует один класс реализации закрытия <>c__DisplayClass1_0
, который содержит как hashSet
, так и file
. Как результат, возвращаемый делегат CreateStream
содержит и сохраняет ссылку на объект hashSet
, который должен был быть доступен для GC после возврата TestMethod
.
В реальном сценарии, где я столкнулся с этой проблемой, очень существенный (т.е. > 100 МБ) объект неправильно заключен.
Мои конкретные вопросы:
- Это ошибка? Если нет, то почему это поведение считается желательным?
Update:
Спецификация С# 5 7.15.5.1 гласит:
Когда внешняя переменная ссылается на анонимную функцию, считается, что внешняя переменная была захвачена анонимным функция. Обычно время жизни локальной переменной ограничено выполнение блока или оператора, с которым оно связано (§5.1.7). Однако время жизни захваченной внешней переменной расширено, по крайней мере, до тех пор, пока дерево делегатов или выражений, созданное из анонимная функция становится пригодной для сбора мусора.
Это, казалось бы, открыто для некоторой степени интерпретации и не запрещает явным образом лямбду от захвата переменных, которые она не ссылается. Тем не менее, этот вопрос охватывает связанный сценарий, который @eric-lippert считается ошибкой. IMHO, я вижу, что комбинированная реализация закрытия, предоставленная компилятором, является хорошей оптимизацией, но что оптимизация не должна использоваться для lambdas, которую может разумно обнаружить компилятор, может иметь время жизни за пределами текущего кадра стека.
- Как я могу противопоставить это, не отказываясь от использования лямбда? Примечательно, как я могу защищать себя от этого, так что будущие изменения кода не заставят некоторые другие неизменные лямбда в одном и том же методе запускать что-то, что ему не нужно?
Update:
Пример кода, который я предоставил, по необходимости надуман. Ясно, что реорганизация лямбда-разработки по отдельному методу работает вокруг проблемы. Мой вопрос не предназначен для проектирования лучших практик (также охватываемых @peter-duniho). Скорее, учитывая содержимое TestMethod
в его нынешнем виде, я хотел бы знать, есть ли способ принудить компилятор исключить лямбда CreateStream
из объединенной реализации закрытия.
Для записи я нацелен на .NET 4.6 с VS 2015.
Ответы
Ответ 1
Это ошибка?
Нет. Компилятор соответствует спецификации здесь.
Почему такое поведение считается желательным?
Это нежелательно. Это очень печально, как вы обнаружили здесь, и, как я описал в 2007 году:
http://blogs.msdn.com/b/ericlippert/archive/2007/06/06/fyi-c-and-vb-closures-are-per-scope.aspx
Команда компилятора С# рассмотрела вопрос об исправлении этого в каждой версии начиная с С# 3.0 и никогда не занимала достаточно высокого приоритета. Подумайте о том, чтобы ввести вопрос на сайт Glyub Roslyn (если его еще нет, может быть и так).
Я лично хотел бы, чтобы это было исправлено; поскольку он стоит, это большая "гоча".
Как я могу противопоставить это, не отказываясь от использования лямбда?
Переменная - это вещь, которая будет захвачена. Вы можете установить для переменной hashset значение null, когда вы закончите с ней. Тогда потребляемая только память - это память для переменной, четыре байта, а не память для вещи, на которую она ссылается, которая будет собрана.
Ответ 2
Я не знаю ничего в спецификации языка С#, которая бы точно определяла, как компилятор должен реализовать анонимные методы и захват переменных. Это деталь реализации.
Что делает спецификация, устанавливаются некоторые правила для того, как должны вести себя анонимные методы и их переменные захвата. У меня нет копии спецификации С# 6, но здесь есть соответствующий текст из спецификации С# 5 в разделе "7.15.5.1 Захваченные внешние переменные":
& hellip; время жизни захваченной внешней переменной расширяется , по крайней мере, до, если дерево делегатов или выражений, созданное из анонимной функции, становится пригодным для сбора мусора. [акцент мой]
В спецификации нет ничего, что ограничивает время жизни переменной. Компилятор просто обязан убедиться, что переменная живет достаточно долго, чтобы оставаться действительной при необходимости анонимным методом.
Итак, & hellip;
1. Это ошибка? Если нет, то почему это поведение считается желательным?
Не ошибка. Компилятор выполняет спецификацию.
Что касается того, считается ли он "желательным", то загруженный термин. Что "желательно" зависит от ваших приоритетов. Тем не менее, одним из приоритетов автора компилятора является упрощение задачи компилятора (и при этом он ускоряется и уменьшает вероятность ошибок). Эта конкретная реализация может считаться "желательной" в этом контексте.
С другой стороны, разработчики языков и авторы компиляторов также имеют общую цель - помочь программистам создать рабочий код. Поскольку детали реализации могут мешать этому, такая деталь реализации может считаться "нежелательной". В конечном счете, это вопрос того, как оценивается каждый из этих приоритетов в соответствии с их потенциально конкурирующими целями.
2. Как я могу противопоставить это, не отказываясь от использования лямбда? Примечательно, как я могу сделать код против этого, так что будущие изменения кода не заставят внезапно заставлять некоторые другие неизменные лямбда в том же методе запускать что-то, что не должно быть?
Трудно сказать без менее надуманного примера. В общем, я бы сказал, что явный ответ: "Не смешивайте свои лямбды". В вашем конкретном (предположительно надуманном) примере у вас есть один метод, который, по-видимому, выполняет две совершенно разные вещи. Обычно это недооценивается по целому ряду причин, и мне кажется, что этот пример просто добавляет к этому списку.
Я не знаю, какой лучший способ исправить "две разные вещи", но очевидной альтернативой было бы, по крайней мере, рефакторинг метода, чтобы метод "двух разных вещей" делегировал работу другому два метода, каждый из которых именуется описательно (который имеет бонусное преимущество, помогая коду быть самодокументированным).
Например:
CreateStream TestMethod( IEnumerable<string> data )
{
string file = "dummy.txt";
var hashSet = new HashSet<string>();
var count = AddAndCountNewItems(data, hashSet);
CreateStream createStream = GetCreateStreamCallback(file);
return createStream;
}
int AddAndCountNewItems(IEnumerable<string> data, HashSet<string> hashSet)
{
return data.Count( s => hashSet.Add( s ) );
}
CreateStream GetCreateStreamCallback(string file)
{
return () => File.OpenRead( file );
}
Таким образом, захваченные переменные остаются независимыми. Даже если компилятор делает по какой-то причудливой причине, все же помещает их в один и тот же тип закрытия, он все равно не должен приводить к тому же экземпляру этого типа, который используется между двумя затворами.
Ваш TestMethod()
все еще выполняет две разные вещи, но по крайней мере сам он не содержит эти две несвязанные реализации. Код более читабельен и лучше разделен, что неплохо, даже если он исправляет проблему с переменной продолжительностью жизни.