* Перегрузка операторов
* Индексаторы

## Перегрузка операторов

Наряду с методами мы можем также перегружать операторы. Например, пусть у нас есть следующий класс Counter:

1
2
3
4
class Counter
{
    public int Value { get; set; }
}
Данный класс представляет некоторый счетчик, значение которого хранится в свойстве Value.

И допустим, у нас есть два объекта класса Counter - два счетчика, которые мы хотим сравнивать или складывать на основании их свойства Value, используя стандартные операции сравнения и сложения:

1
2
3
4
5
Counter c1 = new Counter { Value = 23 };
Counter c2 = new Counter { Value = 45 };
 
bool result = c1 > c2;
Counter c3 = c1 + c2;
Но на данный момент ни операция сравнения, ни операция сложения для объектов Counter не доступны. Эти операции могут использоваться для ряда примитивных типов. Например, по умолчанию мы можем складывать числовые значения, но как складывать объекты комплексных типов - классов и структур компилятор не знает. И для этого нам надо выполнить перегрузку нужных нам операторов.

Перегрузка операторов заключается в определении в классе, для объектов которого мы хотим определить оператор, специального метода:

1
2
public static возвращаемый_тип operator оператор(параметры)
{  }
Этот метод должен иметь модификаторы public static, так как перегружаемый оператор будет использоваться для всех объектов данного класса. Далее идет название возвращаемого типа. Возвращаемый тип представляет тот тип, объекты которого мы хотим получить. К примеру, в результате сложения двух объектов Counter мы ожидаем получить новый объект Counter. А в результате сравнения двух мы хотим получить объект типа bool, который указывает истинно ли условное выражение или ложно. Но в зависимости от задачи возвращаемые типы могут быть любыми.

Затем вместо названия метода идет ключевое слово operator и собственно сам оператор. И далее в скобках перечисляются параметры. Бинарные операторы принимают два параметра, унарные - один параметр. И в любом случае один из параметров должен представлять тот тип - класс или структуру, в котором определяется оператор.

Например, перегрузим ряд операторов для класса Counter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Counter
{
    public int Value { get; set; }
         
    public static Counter operator +(Counter c1, Counter c2)
    {
        return new Counter { Value = c1.Value + c2.Value };
    }
    public static bool operator >(Counter c1, Counter c2)
    {
        return c1.Value > c2.Value;
    }
    public static bool operator <(Counter c1, Counter c2)
    {
        return c1.Value < c2.Value;
    }
}
Поскольку все перегруженные операторы - бинарные - то есть проводятся над двумя объектами, то для каждой перегрузки предусмотрено по два параметра.

Так как в случае с операцией сложения мы хотим сложить два объекта класса Counter, то оператор принимает два объекта этого класса. И так как мы хотим в результате сложения получить новый объект Counter, то данный класс также используется в качестве возвращаемого типа. Все действия этого оператора сводятся к созданию, нового объекта, свойство Value которого объединяет значения свойства Value обоих параметров:

1
2
3
4
public static Counter operator +(Counter c1, Counter c2)
{
    return new Counter { Value = c1.Value + c2.Value };
}
Также переопределены две операции сравнения. Если мы переопределяем одну из этих операций сравнения, то мы также должны переопределить вторую из этих операций. Сами операторы сравнения сравнивают значения свойств Value и в зависимости от результата сравнения возвращают либо true, либо false.

Теперь используем перегруженные операторы в программе:

1
2
3
4
5
6
7
8
9
10
11
12
static void Main(string[] args)
{
    Counter c1 = new Counter { Value = 23 };
    Counter c2 = new Counter { Value = 45 };
    bool result = c1 > c2;
    Console.WriteLine(result); // false
 
    Counter c3 = c1 + c2;
    Console.WriteLine(c3.Value);  // 23 + 45 = 68
     
    Console.ReadKey();
}
Стоит отметить, что так как по сути определение оператора представляет собой метод, то этот метод мы также можем перегрузить, то есть создать для него еще одну версию. Например, добавим в класс Counter еще один оператор:

1
2
3
4
public static int operator +(Counter c1, int val)
{
    return c1.Value + val;
}
Данный метод складывает значение свойства Value и некоторое число, возвращая их сумму. И также мы можем применить этот оператор:

1
2
3
Counter c1 = new Counter { Value = 23 };
int d = c1 + 27; // 50
Console.WriteLine(d);
Следует учитывать, что при перегрузке не должны изменяться те объекты, которые передаются в оператор через параметры. Например, мы можем определить для класса Counter оператор инкремента:

1
2
3
4
5
public static Counter operator ++(Counter c1)
{
    c1.Value += 10;
    return c1;
}
Поскольку оператор унарный, он принимает только один параметр - объект того класса, в котором данный оператор определен. Но это неправильное определение инкремента, так как оператор не должен менять значения своих параметров.

И более корректная перегрузка оператора инкремента будет выглядеть так:

1
2
3
4
public static Counter operator ++(Counter c1)
{
    return new Counter { Value = c1.Value + 10 };
}
То есть возвращается новый объект, который содержит в свойстве Value инкрементированное значение.

При этом нам не надо определять отдельно операторы для префиксного и для постфиксного инкремента (а также декремента), так как одна реализация будет работать в обоих случаях.

Например, используем операцию префиксного инкремента:

1
2
3
4
Counter counter = new Counter() { Value = 10 };
Console.WriteLine($"{counter.Value}");      // 10
Console.WriteLine($"{(++counter).Value}");  // 20
Console.WriteLine($"{counter.Value}");      // 20
Консольный вывод:

10
20
20
Теперь используем постфиксный инкремент:

1
2
3
4
Counter counter = new Counter() { Value = 10 };
Console.WriteLine($"{counter.Value}");      // 10
Console.WriteLine($"{(counter++).Value}");  // 10
Console.WriteLine($"{counter.Value}");      // 20
Консольный вывод:

10
10
20
Также стоит отметить, что мы можем переопределить операторы true и false. Например, определим их в классе Counter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Counter
{
    public int Value { get; set; }
     
    public static bool operator true(Counter c1)
    {
        return c1.Value != 0;
    }
    public static bool operator false(Counter c1)
    {
        return c1.Value == 0;
    }
     
    // остальное содержимое класса
}
Эти операторы перегружаются, когда мы хотим использовать объект типа в качестве условия. Например:

1
2
3
4
5
Counter counter = new Counter() { Value = 0 };
if (counter)
    Console.WriteLine(true);
else
    Console.WriteLine(false);
При перегрузке операторов надо учитывать, что не все операторы можно перегрузить. В частности, мы можем перегрузить следующие операторы:

унарные операторы +, -, !, ~, ++, --

бинарные операторы +, -, *, /, %

операции сравнения ==, !=, <, >, <=, >=

логические операторы &&, ||

И есть ряд операторов, которые нельзя перегрузить, например, операцию равенства = или тернарный оператор ?:, а также ряд других.

Полный список перегружаемых операторов можно найти в документации msdn

При перегрузке операторов также следует помнить, что мы не можем изменить приоритет оператора или его ассоциативность, мы не можем создать новый оператор или изменить логику операторов в типах, который есть по умолчанию в .NET.

## Индексаторы

Индексаторы позволяют индексировать объекты и обращаться к данным по индексу. Фактически с помощью индексаторов мы можем работать с объектами как с массивами. По форме они напоминают свойства со стандартными блоками get и set, которые возвращают и присваивают значение.

Формальное определение индексатора:

1
2
3
4
5
возвращаемый_тип this [Тип параметр1, ...]
{
    get { ... }
    set { ... }
}
В отличие от свойств индексатор не имеет названия. Вместо него указывается ключевое слово this, после которого в квадратных скобках идут параметры. Индексатор должен иметь как минимум один параметр.

Посмотрим на примере. Допустим, у нас есть класс Person, который представляет человека, и класс People, который представляет группу людей. Используем индексаторы для определения класса People:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Person
{
    public string Name { get; set; }
}
class People
{
    Person[] data;
    public People()
    {
        data = new Person[5];
    }
    // индексатор
    public Person this[int index]
    {
        get
        {
            return data[index];
        }
        set
        {
            data[index] = value;
        }
    }
}
Конструкция public Person this[int index] и представляет индексатор. Здесь определяем, во-первых, тип возвращаемого или присваиваемого объекта, то есть тип Person. Во-вторых, определяем через параметр int index способ доступа к элементам.

По сути все объекты Person хранятся в классе в массиве data. Для получения их по индексу в индексаторе определен блок get:

1
2
3
4
get
{
    return data[index];
}
Поскольку индексатор имеет тип Person, то в блоке get нам надо возвратить объект этого типа с помощью оператора return. Здесь мы можем определить разнообразную логику. В данном случае просто возвращаем объект из массива data.

В блоке set получаем через параметр value переданный объект Person и сохраняем его в массив по индексу.

1
2
3
4
set
{
    data[index] = value;
}
После этого мы можем работать с объектом People как с набором объектов Person:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Program
    {
    static void Main(string[] args)
    {
        People people = new People();
        people[0] = new Person { Name = "Tom" };
        people[1] = new Person { Name = "Bob" };
 
        Person tom = people[0];
        Console.WriteLine(tom?.Name);
 
        Console.ReadKey();
    }
}
Индексатор, как полагается получает набор индексов в виде параметров. Однако индексы необязательно должны представлять тип int. Например, мы можем рассматривать объект как хранилище свойств и передавать имя атрибута объекта в виде строки:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class User
{
    string name;
    string email;
    string phone;
 
    public string this[string propname]
    {
        get
        {
            switch (propname)
            {
                case "name": return "Mr/Ms. " + name;
                case "email": return email;
                case "phone": return phone;
                default: return null;
            }
        }
        set
        {
            switch (propname)
            {
                case "name":
                    name = value;
                    break;
                case "email":
                    email = value;
                    break;
                case "phone":
                    phone = value;
                    break;
            }
        }
    }
}
class Program
{
    static void Main(string[] args)
    {
        User tom = new User();
        tom["name"] = "Tom";
        tom["email"] = "tomekvilmovskiy@gmail.ru";
 
        Console.WriteLine(tom["name"]); // Mr/Ms. Tom
 
        Console.ReadKey();
    }
}
Применение нескольких параметров
Также индексатор может принимать несколько параметров. Допустим, у нас есть класс, в котором хранилище определено в виде двухмерного массива или матрицы:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Matrix
{
    private int[,] numbers = new int[,] { { 1, 2, 4}, { 2, 3, 6 }, { 3, 4, 8 } };
    public int this[int i, int j]
    {
        get
        {
            return numbers[i,j];
        }
        set
        {
            numbers[i, j] = value;
        }
    }
}
Теперь для определения индексатора используются два индекса - i и j. И в программе мы уже должны обращаться к объекту, используя два индекса:

1
2
3
4
Matrix matrix = new Matrix();
Console.WriteLine(matrix[0, 0]);
matrix[0, 0] = 111;
Console.WriteLine(matrix[0, 0]);
Следует учитывать, что индексатор не может быть статическим и применяется только к экземпляру класса. Но при этом индексаторы могут быть виртуальными и абстрактными и могут переопределяться в произодных классах.

Блоки get и set
Как и в свойствах, в индексаторах можно опускать блок get или set, если в них нет необходимости. Например, удалим блок set и сделаем индексатор доступным только для чтения:

1
2
3
4
5
6
7
8
9
10
11
class Matrix
{
    private int[,] numbers = new int[,] { { 1, 2, 4}, { 2, 3, 6 }, { 3, 4, 8 } };
    public int this[int i, int j]
    {
        get
        {
            return numbers[i,j];
        }
    }
}
Также мы можем ограничивать доступ к блокам get и set, используя модификаторы доступа. Например, сделаем блок set приватным:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Matrix
{
    private int[,] numbers = new int[,] { { 1, 2, 4}, { 2, 3, 6 }, { 3, 4, 8 } };
    public int this[int i, int j]
    {
        get
        {
            return numbers[i,j];
        }
        private set
        {
            numbers[i, j] = value;
        }
    }
}
Перегрузка индексаторов
Подобно методам индексаторы можно перегружать. В этом случае также индексаторы должны отличаться по количеству, типу или порядку используемых параметров. Например:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}
class People
{
    Person[] data;
    public People()
    {
        data = new Person[5];
    }
    public Person this[int index]
    {
        get
        {
            return data[index];
        }
        set
        {
            data[index] = value;
        }
    }
    public Person this[string name]
    {
        get
        {
            Person person = null;
            foreach(var p in data)
            {
                if(p?.Name == name)
                {
                    person = p;
                    break;
                }
            }
            return person;
        }
    }
}
class Program
{
    static void Main(string[] args)
    {
        People people = new People();
        people[0] = new Person { Name = "Tom" };
        people[1] = new Person { Name = "Bob" };
             
        Console.WriteLine(people[0].Name);      // Tom
        Console.WriteLine(people["Bob"].Name);  // Bob
 
        Console.ReadKey();
    }
}
В данном случае класс People содержит две версии индексатора. Первая версия получает и устанавливает объект Person по индексу, а вторая - только получае объект Person по его имени.