«При наличии общего механизма перегрузки функций аргументы по умолчанию становятся логически избыточными и в лучшем случае обеспечивают небольшое удобство нотации»
Б. Страуструп «Дизайн и эволюция С++»
Многие разработчики, перешедшие в .NET с языка С++ хотели увидеть в C# аргументы по умолчанию. И вот в C# 4.0 они появились, но с существенными ограничениями, особенно по сравнению с языком С++. Конечно эти особенности и ограничения не сложно запомнить, а их перечисление займет не больше пары строк, но вместо простого перечисления этих особенностей я предлагаю побывать в шкуре разработчиков этой фичи для языка платформы .NET и прийти к этим ограничениям и особенностям самостоятельно.
Варианты реализации
Смысл аргументов по умолчанию очень простой: класс, предоставляющий метод знает значение аргумента, подходящее многим его клиентам по умолчанию. Поэтому вместо перегрузки методов он использует один метод и делает один или несколько аргументов не обязательными:
class StreamWrapper
{
public static void UseStream(Stream stream, bool needClose = true)
{ }
}
Прежде чем рассматривать реализацию аргументов по умолчанию в языке C# давайте рассмотрим варианты реализации этой возможности в принципе.
1. Использование перегрузки методов
Так, например, при наличии аргументов по умолчанию, компилятор мог бы создать два метода; аналогично тому, как мы поступаем в языках без аргументов по умолчанию:
// UseStream(Stream stream, bool needClose = true) преоразуется в 2 метода:
public static void UseStream(Stream stream, bool needClose)
{ }
public static void UseStream(Stream stream)
{
UseStream(stream, true);
}
Это решение на первый взгляд может показаться разумным, но оно приведет к ряду проблем:
1. Как быть со стек-трейсом? Если при вызове второго метода произойдет исключение, то стек-трейс будет содержать детали реализации этой возможности. Можно заставить компилятор «инлайнить» второй вызов и просто продублировать код из оригинального метода, но это не всегда просто и возможно.
2. Как быть с языками, не знающими об аргументах по умолчанию? Для них среда разработки будет показывать два метода, что совсем не удобно!
3. Как быть с виртуальными методами и интерфейсами? Опять-таки, в языках, не знающих об аргументах по умолчанию появится возможность переопределить или реализовать лишь один метод.
Другими словами, чтобы реализовать аргументы по умолчанию таким способом потребуются изменения среды исполнения и других языков программирования. Поскольку аргументы по умолчанию – это скорее хотелка, то такие ограничения сделают эту возможность экономически не выгодной с точки зрения ее разработки (и я не знаю ни одного примера, где аргументы по умолчанию были бы реализованы таким образом).
2. Подстановка значений по умолчанию в вызывающем коде
Альтернативным решением, будет использование одного метода и в случае вызова метода без одного из параметров, компилятор подставит «втихаря» значения по умолчанию:
StreamWrapper.UseStream(stream);
// Эта строка заменяется компилятором в *месте вызова* на следующую:
// bool defVal = GetDefaultValueFromMetadata();
// StreamWrapper.UseStream(ms, defVal);
Именно таким образом реализованы аргументы по умолчанию в VB и С++, и именно так они и реализованы в C#. И хотя идея реализации этой возможности в языках С++ и C# похожи, разница тоже есть. Для подстановки значения по умолчанию в месте вызова компилятору нужно «добраться» до значения по умолчанию. И если для С++ это значит использование заголовочного файла, то в случае платформы .NET мы должны сохранить значение по умолчанию в метаданных сборки.
Реализация аргументов по умолчанию в C#
Итак, в языке C# используется подстановка значения по умолчанию в месте вызова метода. Как бы мы с вами реализовали эту возможность? Во-первых, при компиляции метода с аргументами по умолчанию, это значение мы бы сохранили в метаданных с помощью атрибутов (где же его еще хранить?).
В результате метод UseStream:
class StreamWrapper
{
public static void UseStream(Stream stream, bool needClose = true)
{ }
}
Во время компиляции преобразовали бы его во что-то такое:
public static void UseStream(
Stream stream,
[Optional][DefaultParameterValue(value: true)]bool needClose)
{ }
После чего, изменили бы overload resolution таким образом, чтобы приведенный выше метод был доступен в случае вызова UseStream(ms). Затем в месте вызова осталось бы достать значение атрибута DefaultParameterValue и подставить его значение Value в качестве аргумента needClose.
ПРИМЕЧАНИЕ
Аргументы по умолчанию интегрированы в платформу .NET несколько сильнее, чем я описал. Так, в метаданных параметров метода отсутствует DefaultParameterValueAttribute, вместо этого, значение по умолчанию будет храниться непосредственно в экземпляре ParameterInfo.
Ограничения аргументов по умолчанию
Поскольку теперь мы знаем, что аргументы по умолчанию в C# реализованы на основе атрибутов и подстановки значения по умолчанию в месте вызова, то не сложно догадаться об ограничениях и особенностях этой возможности.
1. Ограничение значений по умолчанию
Поскольку значение по умолчанию хранится в атрибутах, то мы не можем использовать в качестве аргумента по умолчанию произвольные выражения:
void WithDateTime(DateTime dt = DateTime.Now) { }
// Будет компилироваться в случае: string s = ""
void WithStringEmpty(string s = String.Empty) { }
void WithMethodCall(int id = GetInvalidId()) { }
static int GetInvalidId() { return -1; }
Пользовательские атрибуты на данный момент могут хранить лишь константы времени компиляции, а значит аргументы по умолчанию также ограничены лишь константами. Можно было бы предложить компилятору «сериализовывать» выражения и сохранять его в атрибутах, но это решение потребовало бы на порядок больше усилий.
2. Проблема версионирования
«Встраивание» значение по умолчанию в место вызова происходит во время компиляции, а не во время исполнения. А значит мы получаем проблему версионирования, аналогичную той, что мы имеем с константами. При изменении значения по умолчанию метода, нам нужно перекомпилировать всех его клиентов, поскольку в противном случае, мы будем вызывать метод со старым значением по умолчанию.
3. Переопределение (overriding) значений по умолчанию
Поиск значения по умолчанию происходит во время компиляции, а это значит, что смешивать аргументы по умолчанию и полиморфизм нельзя. Давайте рассмотрим следующий код:
public class StreamWrapper
{
public virtual void UseStream(Stream stream, bool needClose = true)
{
Console.WriteLine("NeedClose: {0}", needClose);
}
}
public class AdvancedStreamWrapper : StreamWrapper
{
public override void UseStream(Stream stream, bool needClose = false)
{
base.UseStream(stream, needClose);
}
}
var ms = new MemoryStream();
var streamWrapper = new AdvancedStreamWrapper();
streamWrapper.UseStream(ms);
((StreamWrapper)streamWrapper).UseStream(ms);
В этом случае значение аргумента по умолчанию будет зависеть от «статического» типа переменной, через которую мы вызываем метод UseStream, поэтому в первом случае мы получим needClose равный False, а во втором случае – needClose равный True.
Это довольно известная проблема в языке С++, и особенности реализации аргументов по умолчанию привнесли ее и в C#. При этом, тот же РеШарпер сразу же предупреждает об “Redundant method override” (хотя я бы посоветовал сменить предупреждение на ошибку, поскольку речь идет не просто об избыточности, а об изменении поведения!).
3. Использование специального типа Optional<T>
На самом деле, существует еще как минимум один распространенный способ реализации аргументов по умолчанию. Вместо подстановки значения в месте вызова, мы могли бы получать значение в теле вызываемого метода, изменив тип аргумента с T на Optional<T>:
public void UseStream(Stream stream, Optional<bool> needClose)
{
bool close = needClose.HasValue ? needClose.Value : true;
// Или даже так
bool close2 =
needClose.HasValue ? needClose.Value : GetDefaultNeedClose();
}
private static bool GetDefaultNeedClose()
{
return true;
}
Именно такой подход применяется в языке F#:
type CustomStreamWrapper() =
let getNeedClose() = true
// Необязательные аргументы можно использовать только в типах
member public x.useStream (s: System.IO.Stream, ?needClose: bool) =
let nc = defaultArg needClose (getNeedClose())
// close stream if nc is true
nc
Такой подход позволяет использовать выражения произвольной сложности для указания значения по умолчанию, хорошо работает с полиморфизмом (поскольку значение по умолчанию определяется во время исполнения, а не во время компиляции), но обладает своими недостатками.
Во-первых, значение по умолчанию становится неочевидным, поскольку его получение может быть в произвольном месте в коде метода.
Во-вторых, изменение простого аргумента на аргумент по умолчанию изменяет сигнатуру метода (ведь изменяется тип аргумента). В языках C++ и C# добавление значения по умолчанию аргументу не является «ломающим» изменением. В этом же случае, мы сломаем код, вызывающий метод через рефлексию, а также нам придется перекомпилировать всех клиентов этого метода. Это не страшно для промышленного кода, но может быть проблемным для библиотек.
UPDATE. Дополнительные ссылки
- C# Bug: Using Reflection with Default Properties
- Eric Lippert. Optional argument corner cases, part one
- Eric Lippert. Optional argument corner cases, part two
- Eric Lippert. Optional argument corner cases, part three
- Eric Lippert. Optional argument corner cases, part four
На самом деле, существует еще как минимум один распространенный способ реализации аргументов по умолчанию. Вместо подстановки значения в месте вызова, мы могли бы получать значение в теле вызываемого метода, изменив тип аргумента с T на Optionarl:
ОтветитьУдалитья так думаю, что имелось ввиду вместо Optionarl написать Optional
@Igor: спасибо, поправил.
ОтветитьУдалитьА ещё вариант с Optional - один из самых популярнейших способов задачи аргументов по-умолчанию в скриптовых языках :) (JavaScript, Tcl, Perl,...). Когда один метод нуждается в разных комбинациях указания аргументов, тогда мне больше нравится вариант с структурой, которая и будет давать нужную информацию. Недавно на C++11 под gcc-4.6.3 у коллеги не работало делегирование конструктора. Там int давалась семантика размерности. Просто что бы посмеятся попробовали вариант с стуктурой с неявными конструкторами. Получился неплохой способ решения проблемы комбинации параметров. Если есть 4 аргумента и вместо каждого из них можно использовать примитивный тип (ну что бы литералы указывать и не парится), а аргументы стандартных типов (и/или семантика неявных конструкторов неустраивает), то вместо 4! = 24 конструктора получается один и 4 структуры в которых по два конструктора.
ОтветитьУдалитьКоля, спасибо!
ОтветитьУдалитьВариант со структурой и правда интересный. Это, имхо, олин из случаев когда дополнительная сущность и неявное преобразование кажется очень разумным.
В принципе, вариант с Optional<T> это частный случай реализованного варианта. Легко можно использовать, к примеру, null в качестве значения по-умолчания (nullable для struct) и подставлять значения динамически.
ОтветитьУдалить@Игорь: а как разделить "отсутствие значение" от "заменить на значение по умолчанию" при использовании null в качестве такого "маркера"?
ОтветитьУдалитьИМХО будет совсем не однозначно;)