Диапазоны (Ranges) в C# 8

7e58ea88b2ce80aacdbfedfa62bf2726.png

​Пусть в нашей программе есть массив целых чисел numbers:

static void Main()
{
	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
}

Перед нами стоит задача: получить новый массив, вырезав из массива numbers элементы от индекса 2 до индекса 4 включительно, то есть должен получится массив [4, 2, 3].

Решение 1

Самое первое и простое решение, которое приходит в голову — это решение в лоб:  

  1. Создадим результирующий массив целых чисел result размером 3:  

    static void Main()
    {
    	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
    
    	var result = new int[3];
    }
  2. Пройдемся циклом по нужным индексам массива numbers, а именно с 2 до 4 включительно:  

    static void Main()
    {
    	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
    
    	var result = new int[3];
    
    	for (int i = 2; i <= 4; i++)
    	{
    		
    	}
    }
  3. Запишем в результирующий массив result нужные значения:

    static void Main()
    {
    	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
    
    	var result = new int[3];
    
    	for (int i = 2; i <= 4; i++)
    	{
    		result[i - 2] = numbers[i];
    	}
    }
  4. Выведем массив result и убедимся, что все ОК:  

    static void Main()
    {
    	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
    
    	var result = new int[3];
    
    	for (int i = 2; i <= 4; i++)
    	{
    		result[i - 2] = numbers[i];
    	}
    
    	Console.WriteLine(string.Join(" ", result)); // 4 2 3
    }

С задачей мы справились. Но есть некоторые недостатки:  

  1. Для решения такой маленькой задачи, пришлось пройтись циклом.  

  2. По коду не сразу понятно, что он делает. Таким образом страдает читаемость.  

  3. Также можно ошибиться с индексами (относится к начинающим программистам). 

Следовательно, такое решение нас не устраивает.  

Решение 2

Немногие знают, что у списка (List) есть готовый метод GetRange(int index, int count), который получает из списка нужный диапазон элементов. Метод первым параметром принимаем index — индекс начала диапазона, а вторым параметром count — количество элементов, которые нужно получить. Например:  

  • GetRange(0, 5) — получает 5 элементов, начиная с индекса 0.  

  • GetRange(3, 10) — получает 10 элементов, начиная с индекса 3.

Тогда сделаем следующее:  

  1. Для того чтобы мы воспользовались готовым методом GetRange, преобразуем массив в список с помощью метода ToList:

    static void Main()
    {
    	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
    
    	var list = numbers.ToList();
    }
  2. Воспользуемся методом GetRange. Нам нужно взять 3 элемента, начиная с индекса 2:

    static void Main()
    {
    	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
    
    	var list = numbers.ToList();
    
    	var resultList = list.GetRange(2, 3);
    }
  3. Метод GetRange вернул результат в виде списка (List). Для того чтобы преобразовать его в массив, воспользуемся методом ToArray:

    static void Main()
    {
    	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
    
    	var list = numbers.ToList();
    
    	var resultList = list.GetRange(2, 3);
    
    	var result = resultList.ToArray();
    }
  4. Выведем массив result и убедимся, что все ОК:  

    static void Main()
    {
    	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
    
    	var list = numbers.ToList();
    
    	var resultList = list.GetRange(2, 3);
    
    	var result = resultList.ToArray();
    
    	Console.WriteLine(string.Join(" ", result)); // 4 2 3
    }

С задачей мы справились. Но есть некоторые недостатки:  

  1. Для решения такой маленькой задачи, пришлось воспользоваться тремя дополнительными методами.  

  2. По коду не сразу понятно, что он делает. Таким образом страдает читаемость.  

  3. Также можно ошибиться при передаче параметров в метод GetRange (относится к начинающим программистам).   

  4. Данные преобразования ресурсоемкие по памяти и производительности. Вызовы ToList, ToArray проходятся по коллекции и выделяют новую память. 

Следовательно, такое решение нас не устраивает. 

Решение 3

Можно еще воспользоваться технологией LINQ, а именно двумя методами:  

  1. Skip(int count) — возвращает все элементы коллекции, кроме первых count.   

  2. Take(int count) — возвращает первые count элементов коллекции. 

В нашем случае, для того, чтобы взять элементы массива от индекса 2 до индекса 4 включительно, нужно пропустить 2 элемента последовательности, а затем взять первые 3 элемента. Как раз получатся элементы с индексами от 2 до 4

Посмотрим в коде:

static void Main()
{
	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };

	var temp = numbers.Skip(2).Take(3);

	var result = temp.ToArray();

	Console.WriteLine(string.Join(" ", result)); // 4 2 3
}

С задачей мы справились. Но есть некоторые недостатки:  

  1. Для решения такой маленькой задачи, пришлось воспользоваться тремя дополнительными методами.  

  2. Можно ошибиться при передаче параметров в методы Skip и Take (относится к начинающим программистам).  

  3. Данные преобразования ресурсоемкие по памяти и производительности. 

Следовательно, такое решение нас не устраивает. 

Решение 4

Есть еще статический метод Copy у класса Array:

Copy(Array sourceArray, int sourceIndex, Array destinationArray, int destinationIndex, int length)

Данный метод копирует элементы из одного массива в другой. Давайте поясним каждый параметр:

  1. Array sourceArray — массив, с которого копируем элементы.  

  2. int sourceIndex — с какого индекса из массива sourceArray начинаем копировать элементы.  

  3. Array destinationArray — массив, в который копируются элементы.  

  4. int destinationIndex — начиная с какого индекса в результирующем массиве destinationArray вставляются элементы.  

  5. int length — количество элементов, которое нужно скопировать.

Давайте воспользуемся данным методом:  

  1. Создадим результирующий массив целых чисел result размером 3:  

    static void Main()
    {
    	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
    
    	var result = new int[3];
    }
  2. Вызываем метод Copy. Передаем массив numbers и индекс 2 — откуда начинаем вырезать элементы. Затем передаем результирующий массив result и индекс 0 — с какого индекса вставляются элементы. А затем передаем 3 — количество элементов, которое нужно скопировать:

    static void Main()
    {
    	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
    
    	var result = new int[3];
    	Array.Copy(numbers, 2, result, 0, 3);
    }
  3. Выведем массив result и убедимся, что все ОК:  

    static void Main()
    {
    	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };
    
    	var result = new int[3];
    	Array.Copy(numbers, 2, result, 0, 3);
    
    	Console.WriteLine(string.Join(" ", result)); // 4 2 3
    }

С задачей мы справились. Но есть некоторые недостатки:  

  1. Легко можно ошибиться при передаче параметров в метод Copy (относится к начинающим программистам).   

  2. Не сразу понятно как использовать метод Copy, ведь он ничего не возвращает. Нужно понять, что результат возвращается в массиве, который был передан третьим параметром. 

Следовательно, такое решение нас не устраивает. 

Решение 5

В C# 8 версии добавили дополнительную функциональность для работы с диапазонами (Range). Теперь для решения нашей задачи можно написать вот так:  

static void Main()
{
	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };

	var result = numbers[2..5];

	Console.WriteLine(string.Join(" ", result)); // 4 2 3
}

То есть, для того чтобы получить некоторый диапазон из коллекции, нужно в квадратных скобках указать начальный индекс, затем .. и наконец индекс конца (!!! НЕ включительно !!!)

Например:

  • numbers[3..10] — вырезает элементы, начиная с индекса 3 и заканчивая индексом 9. Напоминаю, что правая граница не включается.  

  • numbers[1..7] — вырезает элементы, начиная с индекса 1 и заканчивая индексом 6

Если индексы будут равны между собой, то в результате получится массив нулевой длины:

static void Main()
{
	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };

	var result = numbers[2..2];

	Console.WriteLine(result.Length); // 0
}

Если первый индекс будет больше второго индекса, то возникнет исключение ArgumentOutOfRangeException во время выполнения программы:

static void Main()
{
	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };

	var result = numbers[5..2]; // ArgumentOutOfRangeException
}

Можно использовать также индексацию справа налево (Indices), введенную тоже в C# 8 версии, про которую говорили совсем недавно:

static void Main()
{
	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };

	var result = numbers[^5..^2]; 
	
	Console.WriteLine(string.Join(" ", result)); // 1 4 2 
}

Можно делать еще более веселые штучки:

  • Например, для получения первых n элементов с помощью диапазонов, нужно написать numbers[0..n]. Так вот, специально для случаев, когда вы хотите взять диапазон с начала массива (когда первый индекс равен 0), придумали упрощение: можно индекс равный 0 опускать, то есть написать вот так:  numbers[..n]. Такая запись более предпочтительна. 

  • Например, для получения всех элементов, кроме первых n с помощью диапазонов, нужно написать numbers[n..numbers.Length].  Специально для случаев, когда вы хотите взять все элементы, кроме первых n (начиная с индекса n и до конца массива), придумали упрощение. Так как второй индекс всегда равен длине массива, то его можно опустить, то есть написать вот так:  numbers[n..]. Такая запись более предпочтительна.

  • Ну и комбинация этих двух подходов. Для получения полной копии массива, можно написать вот так:  numbers[..], то есть опустить оба индекса. Это означает взять диапазон от начала массива до конца. 

Что там под капотом?

На самом деле любой диапазон в C# 8 версии можно хранить в новом типе данных Range. Он находится в пространстве имен (namespaceSystem, следовательно, никакой дополнительный using при его использовании не нужно писать. 

У Range существует два конструктора:

  • Range() — создает пустой диапазон. 

  • Range(Index start, Index end) — создает диапазон от индекса start (включительно) и до индекса end (НЕ включительно).

Рассмотрим на примерах:

static void Main()
{
	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };

	var range1 = new Range();
	var result = numbers[range1]; // пустой массив


	var range2 = new Range(2, 5);
	result = numbers[range2]; // 4 2 3

	
	var range3 = new Range(1, 3);
	result = numbers[range3]; // 1 4
}

Заметьте, что объект типа Range передается в качестве индекса в квадратные скобки ([]). 

Проведем соответствие между двумя разными записями:

Укороченная версия

Версия с Range

numbers[2..5]

numbers[new Range(2, 5)]

numbers[^6..^2]

numbers[new Range(^6, ^2)]

В Range реализовано неявное преобразование укороченной записи (например 2..5) к Range. Вот как это работает:  

static void Main()
{
	var numbers = new int[] { 5, 1, 4, 2, 3, 7 };

	Range range = 2..5;
	var result = numbers[range]; // 4 2 3
}

У Range переопределен метод Equals:

static void Main()
{
	Range range1 = 2..5;
	Range range2 = 2..5;
	Range range3 = 1..6;

	Console.WriteLine(range1.Equals(range2)); // True
	Console.WriteLine(range1.Equals(range3)); // False
}

А можно вообще вот так:  

static void Main()
{
	Range range1 = 2..5;

	Console.WriteLine(range1.Equals(2..5)); // True
	Console.WriteLine(range1.Equals(1..6)); // False
}

Здесь сначала происходит неявное преобразование укороченной записи к Range, а потом вызов Equals

У Range переопределен также метод ToString:

static void Main()
{
	Range range1 = 2..5;
	Range range2 = ^6..^3;

	Console.WriteLine(range1.ToString()); // 2..5
	Console.WriteLine(range2.ToString()); // ^6..^3
}

Заметьте, что для индексации с конца выводится ^ перед индексом. 

Также теперь мы можем в методы передавать диапазон:

static void Test(int[] numbers, Range range)
{
	// логика
}

Выводы:

  1. Структура Range позволяет создать экземпляр, к которому можно обращаться многократно.

  2. Код становится более короткий и читаемый.

  3. Увеличивается производительность без обращения к лишним методам.

  4. Меньше нагрузка на память.

PS. Написано с любовью вместе со своими учениками. Они у меня лучшие

© Habrahabr.ru