понедельник, 24 июня 2013 г.

Аргументы по умолчанию в C#

«При наличии общего механизма перегрузки функций аргументы по умолчанию становятся логически избыточными и в лучшем случае обеспечивают небольшое удобство нотации»
                                                                                            Б. Страуструп «Дизайн и эволюция С++»

Многие разработчики, перешедшие в .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. Дополнительные ссылки

  1. C# Bug: Using Reflection with Default Properties
  2. Eric Lippert. Optional argument corner cases, part one
  3. Eric Lippert. Optional argument corner cases, part two
  4. Eric Lippert. Optional argument corner cases, part three
  5. Eric Lippert. Optional argument corner cases, part four

6 комментариев:

  1. На самом деле, существует еще как минимум один распространенный способ реализации аргументов по умолчанию. Вместо подстановки значения в месте вызова, мы могли бы получать значение в теле вызываемого метода, изменив тип аргумента с T на Optionarl:

    я так думаю, что имелось ввиду вместо Optionarl написать Optional

    ОтветитьУдалить
  2. А ещё вариант с Optional - один из самых популярнейших способов задачи аргументов по-умолчанию в скриптовых языках :) (JavaScript, Tcl, Perl,...). Когда один метод нуждается в разных комбинациях указания аргументов, тогда мне больше нравится вариант с структурой, которая и будет давать нужную информацию. Недавно на C++11 под gcc-4.6.3 у коллеги не работало делегирование конструктора. Там int давалась семантика размерности. Просто что бы посмеятся попробовали вариант с стуктурой с неявными конструкторами. Получился неплохой способ решения проблемы комбинации параметров. Если есть 4 аргумента и вместо каждого из них можно использовать примитивный тип (ну что бы литералы указывать и не парится), а аргументы стандартных типов (и/или семантика неявных конструкторов неустраивает), то вместо 4! = 24 конструктора получается один и 4 структуры в которых по два конструктора.

    ОтветитьУдалить
  3. Коля, спасибо!
    Вариант со структурой и правда интересный. Это, имхо, олин из случаев когда дополнительная сущность и неявное преобразование кажется очень разумным.

    ОтветитьУдалить
  4. В принципе, вариант с Optional<T> это частный случай реализованного варианта. Легко можно использовать, к примеру, null в качестве значения по-умолчания (nullable для struct) и подставлять значения динамически.

    ОтветитьУдалить
  5. @Игорь: а как разделить "отсутствие значение" от "заменить на значение по умолчанию" при использовании null в качестве такого "маркера"?

    ИМХО будет совсем не однозначно;)

    ОтветитьУдалить