Friday, June 20, 2014

Мифы в C#: По умолчанию объекты C# передаются по ссылке

В этой статье мы развеим один популярный миф, который бытует среди многих разработчиков языка C#. Мы рассмотрим, почему это миф, используя для этой цели книгу Jon Skeet, "C# in Depth, 3rd Edition", MSDN "Passing Parameters (C# Programming Guide)", и поскольку для таких целей лучше всего доказывать на практике, мы будем использовать для примеров язык C#, а также смотреть в IL код. В своей книге Скит называет этот миф так "Objects are passed by reference in C# by default". 
Небольшое отступление, почему я решил затронуть эту тему. Не так давно, во время прохождения собеседований на должность Software .NET Developer, в двух компаниях мне был задан вопрос, который звучал приблизительно одинаково: "Как передаются аргументы в функцию в C#?". В первой компании с моим ответом согласились после того, как запустили пример, который я набросал. На втором же собеседовании меня пытались убедить в том, что я неправильно понимаю происходящее, и что ссылочные типы передаются по умолчанию по ссылке. Ладно, что, по сути, эти ребята подменяют понятия, которые присутствуют в языке C# и описаны не только в MSDN, но и легендарным Скитом, который, своего рода, Чак Норрис в мире C#. Меня больше поразил тот факт, что эти специалисты аргументировали свои доводы, проецируя передачу аргументов в языке C# на передачу тех же аргументов в языке С++, в котором они затронули тему указателей и ссылок. Для профессиональных разработчиков пытаться накладывать возможности одного языка на другой и возмущаться по поводу отсутствия в другом языке тех или иных возможностей, по крайней мере, странно. Сложно понять специалистов, которые в качестве аргументации передачи объектов в функцию по умолчанию приводят пример с использованием в С++ указателей. 
Давайте начнем с того, что в C# нет указателей в чистом виде. Получить указатель на объект можно, но с помощью разных хаков, которые можно посмотреть в статье "Получение указателя на объект .Net". В C# есть возможность работать с указателями через unsafe, но для этого, если мы работаем через студию, нам нужно внести некоторые настройки в сборку проекта.
Но это все равно не то же, что имелось в виду. Давайте посмотрим, что об этом говорит Скит. Я  процитирую переведенный вариант с второго издания его книги.
Это, вероятно, наиболее широко распространенный миф. Люди, которые утверждают это, зачастую (хоть и не всегда) знают, как фактически ведет себя язык C#, но не знают, что действительно означает термин “передача по ссылке” (pass by reference). К сожалению, это сомнительно для людей, которые действительно знают, что это означает. Формальное определение передачи по ссылке относительно сложно, оно подразумевает такую терминологию, как l-значение (l-value) и подобное, но важно то, что если вы передаете переменную по ссылке, то вызванный метод может изменить значение переменной вызывающей стороны при изменении значения ее параметра. Теперь вспомните, что содержимым переменной ссылочного типа является ссылка, а не сам объект непосредственно. Вы можете изменить содержимое объекта, на который указывает параметр, без передачи самого параметра по ссылке. Например, следующий метод изменяет содержимое объекта StringBuilder, но выражение вызывающей стороны все еще остается тем же объектом, что и прежде.
void AppendHello(StringBuilder builder)
{
    builder.Append("hello");
}
При вызове этого метода значение параметра (ссылка на объект StringBuilder) передается по значению. Например, если я должен изменить в методе значение переменной builder при помощи оператора builder = null, то это изменение, вопреки мифу, не будет замечено вызывающей стороной.
Интересно заметить, что не только часть “по ссылке” выражения является мифом, но и часть “объекты передаются”. Сами объекты никуда не передаются, ни по ссылке, ни по значению. Когда используется ссылочный тип, либо переменная передается по ссылке, либо значение аргумента (ссылка) передается по значению. Кроме всего прочего, это отвечает на вопрос о том, что происходит, когда в качестве аргумента используется значение null, ‒ если бы передавались объекты, возникла бы проблема, поскольку не будет объекта для передачи! Вместо этого ссылка null передается как значение, точно так же как любая другая ссылка.
Если это краткое объяснение оставило у вас вопросы, вы можете обратиться к статье на моем веб‒сайте C# (http://mng.bz/otVt), где тема раскрыта более подробно. Эти мифы не единственные. Упаковка и распаковка вносят свою долю недоразумений, которые я пытаюсь устранить.
Давайте проверим утверждение Скита на примере. Для этого создадим класс SomeClass и добавим в него одну переменную типа string x.
class Program
{
    static void Main(string[] args)
    {
        var someClass = new SomeClass();
        someClass.x = "hello";
        ChangeSomeChass(someClass);
        Console.WriteLine(someClass.x);
    }

    public static void ChangeSomeChass(SomeClass s)
    {
        s = null;
    }
}

public class SomeClass
{
    public string x;
}
Как вы думаете, что будет выведено на экране? Если вы предполагаете, что не будет выведено ничего, то вы ошибаетесь. На экран будет выведено слово "hello".
Вот как будет выглядеть данный код в IL:
.class private auto ansi beforefieldinit ReferenceTypeSample.Program
        extends [mscorlib]System.Object
    {
        // Methods
        .method private hidebysig static
            void Main (
                string[] args
            ) cil managed
        {
            // Method begins at RVA 0x2050
            // Code size 38 (0x26)
            .maxstack 2
            .entrypoint
            .locals init (
                [0] class ReferenceTypeSample.SomeClass someClass
            )

            IL_0000: nop
            IL_0001: newobj instance void ReferenceTypeSample.SomeClass::.ctor()
            IL_0006: stloc.0
            IL_0007: ldloc.0
            IL_0008: ldstr "hello"
            IL_000d: stfld string ReferenceTypeSample.SomeClass::x
            IL_0012: ldloc.0
            IL_0013: call void ReferenceTypeSample.Program::ChangeSomeChass(class ReferenceTypeSample.SomeClass)
            IL_0018: nop
            IL_0019: ldloc.0
            IL_001a: ldfld string ReferenceTypeSample.SomeClass::x
            IL_001f: call void [mscorlib]System.Console::WriteLine(string)
            IL_0024: nop
            IL_0025: ret
        } // end of method Program::Main

        .method public hidebysig static
            void ChangeSomeChass (
                class ReferenceTypeSample.SomeClass s
            ) cil managed
        {
            // Method begins at RVA 0x2082
            // Code size 5 (0x5)
            .maxstack 8

            IL_0000: nop
            IL_0001: ldnull
            IL_0002: starg.s s
            IL_0004: ret
        } // end of method Program::ChangeSomeChass

        .method public hidebysig specialname rtspecialname
            instance void .ctor () cil managed
        {
            // Method begins at RVA 0x2088
            // Code size 7 (0x7)
            .maxstack 8

            IL_0000: ldarg.0
            IL_0001: call instance void [mscorlib]System.Object::.ctor()
            IL_0006: ret
        } // end of method Program::.ctor

    } // end of class ReferenceTypeSample.Program
Давайте попробуем передать наше значение по ссылке, указав для это параметр ref.
class Program
{
    static void Main(string[] args)
    {
        var someClass = new SomeClass();
        someClass.x = "hello";
        ChangeSomeChass(ref someClass);
        Console.WriteLine(someClass == null);
    }

    public static void ChangeSomeChass(ref SomeClass s)
    {
        s = null;
    }
}

public class SomeClass
{
    public string x;
}
После запуска программы увидим, что у нас ссылка на someClass обнулилась.
Давайте посмотрим на то, какие произошли отличия в IL коде.
// Methods
        .method private hidebysig static
            void Main (
                string[] args
            ) cil managed
        {
            // Method begins at RVA 0x2050
            // Code size 37 (0x25)
            .maxstack 2
            .entrypoint
            .locals init (
                [0] class ReferenceTypeSample.SomeClass someClass
            )

            IL_0000: nop
            IL_0001: newobj instance void ReferenceTypeSample.SomeClass::.ctor()
            IL_0006: stloc.0
            IL_0007: ldloc.0
            IL_0008: ldstr "hello"
            IL_000d: stfld string ReferenceTypeSample.SomeClass::x
            IL_0012: ldloca.s someClass
            IL_0014: call void ReferenceTypeSample.Program::ChangeSomeChass(class ReferenceTypeSample.SomeClass&)
            IL_0019: nop
            IL_001a: ldloc.0
            IL_001b: ldnull
            IL_001c: ceq
            IL_001e: call void [mscorlib]System.Console::WriteLine(bool)
            IL_0023: nop
            IL_0024: ret
        } // end of method Program::Main

        .method public hidebysig static
            void ChangeSomeChass (
                class ReferenceTypeSample.SomeClass& s
            ) cil managed
        {
            // Method begins at RVA 0x2081
            // Code size 5 (0x5)
            .maxstack 8

            IL_0000: nop
            IL_0001: ldarg.0
            IL_0002: ldnull
            IL_0003: stind.ref
            IL_0004: ret
        } // end of method Program::ChangeSomeChass

        .method public hidebysig specialname rtspecialname
            instance void .ctor () cil managed
        {
            // Method begins at RVA 0x2087
            // Code size 7 (0x7)
            .maxstack 8

            IL_0000: ldarg.0
            IL_0001: call instance void [mscorlib]System.Object::.ctor()
            IL_0006: ret
        } // end of method Program::.ctor

    } // end of class ReferenceTypeSample.Program
Пройдемся по этих изменениях более детально. Изменился способ вызова функции ChangeSomeClass.
IL_0014: call void ReferenceTypeSample.Program::ChangeSomeChass(class ReferenceTypeSample.SomeClass&)
Если мы посмотрим внимательно на код, то можем увидеть в методе знак &, который указывает, что мы передаем ссылку на исходный объект. Вы, возможно, сталкивались с ссылками в языке С++ и видели амперсанд &,  который и обозначал, что мы работаем со ссылками.
Изменения затронули также саму функцию ChangeSomeClass.
.method public hidebysig static
            void ChangeSomeChass (
                class ReferenceTypeSample.SomeClass& s
            ) cil managed
        {
            // Method begins at RVA 0x2081
            // Code size 5 (0x5)
            .maxstack 8

            IL_0000: nop
            IL_0001: ldarg.0
            IL_0002: ldnull
            IL_0003: stind.ref
            IL_0004: ret
        } // end of method Program::ChangeSomeChass
Посмотрите внимательно на параметр, который передается в метод как аргумент. Мы видим уже знакомый нам амперсанд &, который указывает нам на то, что к нам пришла ссылка на сам объект, который мы можем изменить.
Здесь нет никакой мистики. Просто действительно в первом варианте мы передаем, как говорит Скит, by value (по значению) и by reference (по ссылке). В своей статье, которую Скит приводит в разрушение мифа, есть такое выражение:
This difference is absolutely crucial to understanding parameter passing in C#, and is why I believe it is highly confusing to say that objects are passed by reference by default instead of the correct statement that object references are passed by value by default.
Для тех, кто говорит постоянно о С++ и о том, что там нет такой путаницы, соглашусь. Там мы явно указываем, как мы передаем параметр в функцию.
void ChangeData(SomeClass& s)
{

}

void ChangeData1(SomeClass* s)
{

}

void ChangeData2(SomeClass s)
{

}
Но поскольку у нас не С++, нужно привыкать к терминологии, которую нам дает компания Microsoft. В MSDN мы можем найти следующее описание для передачи ссылочных типов по значению Passing Reference-Type Parameters:
"A variable of a reference type does not contain its data directly; it contains a reference to its data. When you pass a reference-type parameter by value, it is possible to change the data pointed to by the reference, such as the value of a class member. However, you cannot change the value of the reference itself; that is, you cannot use the same reference to allocate memory for a new class and have it persist outside the block. To do that, pass the parameter using the ref or out keyword. For simplicity, the following examples use ref".
Давайте рассмотрим также второй нюанс, который описан в MSDN по ссылке выше, когда мы пытаемся создать новый объект и присвоить его исходному.
class Program
{
    static void Main(string[] args)
    {
        int[] arr = { 1, 4, 5 };
        System.Console.WriteLine("Inside Main, before calling the method, the first element is: {0}", arr[0]);

        Change(arr);
        System.Console.WriteLine("Inside Main, after calling the method, the first element is: {0}", arr[0]);
    }

    static void Change(int[] pArray)
    {
        pArray[0] = 888;  // This change affects the original element.
        pArray = new int[5] {-3, -1, -2, -3, -4};   // This change is local.
        System.Console.WriteLine("Inside the method, the first element is: {0}", pArray[0]);
    }
}
Почему у нас такое поведение, все карты открывает  все тот же MSDN:
"Because the parameter is a reference to arr, it is possible to change the values of the array elements. However, the attempt to reassign the parameter to a different memory location only works inside the method and does not affect the original variable, arr".
Здесь объясняется, что поскольку параметр — это ссылка на arr, мы можем изменить значения элемента этого массива.  Однако переприсвоение входящего параметра не возымеет никакого эффекта из-за определения в разных областях памяти, поэтому переопределение будет работать внутри метода Change не приведет к изменения оригинальной переменной arr.
In the preceding example, the array, arr, which is a reference type, is passed to the method without the ref parameter. In such a case, a copy of the reference, which points to arr, is passed to the method. The output shows that it is possible for the method to change the contents of an array element, in this case from 1 to 888. However, allocating a new portion of memory by using the new operator inside the Change method makes the variable pArray reference a new array. Thus, any changes after that will not affect the original array, arr, which is created inside Main. In fact, two arrays are created in this example, one inside Main and one inside the Change method.
Основная мысль, которую нам пытается здесь донести MSDN, заключается в том, что мы, по сути, передаем в метод копию ссылки, которая ссылается на массив arr.

Самое главное в данной статье которое я хотел бы донести к читателям, которые плотно работают с языком C#, заключается в том, что ссылочные типы по умолчанию передаются как параметры в методы по значению

No comments:

Post a Comment