Словарь в C#: Dictionary — создание, добавление, поиск и перебор

Словарь в C#: Dictionary - создание, добавление, поиск и перебор Полезное

Словарь в C# — это коллекция пар «ключ — значение», в которой значение находят по уникальному ключу. Основная реализация — обобщенный класс Dictionary<TKey, TValue> из пространства имен System.Collections.Generic, внутри это хеш-таблица: поиск, добавление и удаление по ключу в среднем выполняются за O(1), если хеш-функция ключа распределяет значения равномерно.

Ниже — рабочий пример целиком, затем разбор операций: как добавлять (и чем Add отличается от индексатора), как безопасно читать через TryGetValue, как перебирать и какие исключения ловят новички. Примеры проверены на .NET 10 (SDK 10.0.401, C# 14) 24.09.2026.

Три термина, которые часто смешивают:

Термин Что это
словарь абстракция «ключ -> значение» (в других языках — map, ассоциативный массив)
IDictionary<TKey, TValue> интерфейс, контракт словаря
Dictionary<TKey, TValue> конкретный класс на хеш-таблице, реализует этот интерфейс

Минимальный пример: создать, добавить, найти, перебрать

using System;
using System.Collections.Generic;

var ages = new Dictionary<string, int>
{
    ["Анна"] = 28,
    ["Борис"] = 35
};

ages.Add("Вера", 22);          // новый ключ
ages["Анна"] = 29;             // индексатор перезаписывает значение

if (ages.TryGetValue("Борис", out int borisAge))
    Console.WriteLine($"Борис: {borisAge}");

Console.WriteLine($"Есть Глеб? {ages.ContainsKey("Глеб")}");
Console.WriteLine($"Удалили Веру: {ages.Remove("Вера")}");
Console.WriteLine($"Всего: {ages.Count}");

foreach (var (name, age) in ages)
    Console.WriteLine($"{name} -> {age}");

Программа напечатает:

Борис: 35
Есть Глеб? False
Удалили Веру: True
Всего: 2
Анна -> 29
Борис -> 35

Что здесь происходит по строкам:

  • new Dictionary<string, int> { ["Анна"] = 28, ... } — инициализатор с индексатором: создает словарь сразу с данными. Типы в угловых скобках задают тип ключа и тип значения, другие типы компилятор не пропустит.
  • Add добавляет пару и требует, чтобы ключа еще не было.
  • ages["Анна"] = 29 через индексатор либо добавляет пару, либо перезаписывает значение существующего ключа.
  • TryGetValue возвращает true и кладет значение в out-переменную, если ключ найден.
  • Remove возвращает true, если ключ был и его удалили, и false, если удалять было нечего.
  • foreach отдает элементы типа KeyValuePair<string, int>; запись var (name, age) раскладывает пару на ключ и значение.

Добавление: Add, индексатор или TryAdd

Три способа записать значение отличаются поведением на существующем ключе. Выбор зависит от того, что для программы означает повтор ключа: ошибку, обновление или «оставить как было».

Способ Ключа нет Ключ уже есть Когда брать
dict.Add(k, v) добавляет ArgumentException повтор ключа — ошибка в данных
dict[k] = v добавляет перезаписывает значение нужно «добавить или обновить»
dict.TryAdd(k, v) добавляет, true ничего не меняет, false первое значение главное, повторы игнорировать

Типовая ошибка — Add на ключ, который уже есть:

using System;
using System.Collections.Generic;

var ages = new Dictionary<string, int> { ["Анна"] = 29 };
ages.Add("Анна", 30);

Результат — необработанное исключение:

Unhandled exception. System.ArgumentException: An item with the same key has already been added. Key: Анна

Исправление зависит от намерения: ages["Анна"] = 30, если значение нужно обновить, или ages.TryAdd("Анна", 30), если старое значение надо сохранить. TryAdd в этом случае вернет False, а в словаре останется 29.

Чтение по ключу: индексатор, TryGetValue, GetValueOrDefault

Индексатор на чтение (dict[key]) рассчитан на случай, когда ключ точно есть. Если ключа нет, словарь не возвращает null или 0, а бросает исключение:

using System;
using System.Collections.Generic;

var ages = new Dictionary<string, int> { ["Анна"] = 29 };
Console.WriteLine(ages["Глеб"]);
Unhandled exception. System.Collections.Generic.KeyNotFoundException: The given key 'Глеб' was not present in the dictionary.

Исправление — проверка и чтение одним вызовом:

using System;
using System.Collections.Generic;

var ages = new Dictionary<string, int> { ["Анна"] = 29 };

if (ages.TryGetValue("Глеб", out int age))
    Console.WriteLine($"Глеб: {age}");
else
    Console.WriteLine("Глеба нет");

Console.WriteLine(ages.GetValueOrDefault("Глеб"));      // 0
Console.WriteLine(ages.GetValueOrDefault("Глеб", -1));  // -1

Вывод: Глеба нет, затем 0 и -1. Связка if (dict.ContainsKey(k)) x = dict[k]; тоже работает, но ищет ключ дважды; TryGetValue делает один поиск. GetValueOrDefault удобен, когда подходит значение по умолчанию, но для int не различает «ключа нет» и «значение равно 0» — в таком случае нужен TryGetValue.

Еще одно ограничение: ключ не может быть null. ages[null] = 1 бросает ArgumentNullException (параметр key), а вот значение null допустимо, если тип значения ссылочный или nullable.

Перебор словаря и изменение во время foreach

Перебирать можно пары, только ключи (dict.Keys) или только значения (dict.Values). Порядок элементов контрактом не гарантирован: в простых сценариях без удалений он часто совпадает с порядком добавления, но полагаться на это нельзя. Если нужен порядок, сортируйте при выводе (OrderBy) или берите SortedDictionary.

Добавлять новые ключи внутри foreach по тому же словарю нельзя:

using System;
using System.Collections.Generic;

var d = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2, ["c"] = 3 };
foreach (var kv in d)
    d["z" + kv.Key] = 0;
Unhandled exception. System.InvalidOperationException: Collection was modified; enumeration operation may not execute.

Исправление — перебирать копию ключей, а менять оригинал:

using System;
using System.Collections.Generic;
using System.Linq;

var d = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2, ["c"] = 3 };
foreach (var key in d.Keys.ToList())
    d["z" + key] = 0;
Console.WriteLine(d.Count);   // 6

Граница правила: начиная с .NET Core 3.0 Remove и Clear по документации не делают перечислитель недействительным, а прогон на .NET 10 показал, что и перезапись значения существующего ключа внутри foreach исключения не вызывает — падает именно добавление нового ключа. В .NET Framework и .NET Core до 3.0 удаление в цикле тоже бросало InvalidOperationException, поэтому в коде, который должен работать и там, надежнее вариант с копией ключей.

Практическая задача: подсчет слов

Самый частый реальный сценарий словаря — частотный подсчет. TryGetValue при отсутствии ключа кладет в out-переменную значение по умолчанию (0 для int), поэтому код получается без ветвления:

using System;
using System.Collections.Generic;
using System.Linq;

string text = "кот и пес и кот и мышь";
var counts = new Dictionary<string, int>();

foreach (var word in text.Split(' '))
{
    counts.TryGetValue(word, out int c);
    counts[word] = c + 1;
}

foreach (var (word, c) in counts.OrderByDescending(p => p.Value)
                                .ThenBy(p => p.Key, StringComparer.Ordinal))
    Console.WriteLine($"{word}: {c}");
и: 3
кот: 2
мышь: 1
пес: 1

Какие ключи подходят: GetHashCode, Equals и компаратор

Словарь находит элемент в два шага: по GetHashCode() ключа выбирает группу-«корзину», затем внутри нее сравнивает ключи через Equals. Отсюда три практических следствия.

1. Строки по умолчанию сравниваются с учетом регистра. Для ключей вроде логинов или заголовков HTTP передайте компаратор в конструктор:

using System;
using System.Collections.Generic;

var cs = new Dictionary<string, int> { ["Key"] = 1 };
var ci = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["Key"] = 1 };

Console.WriteLine(cs.ContainsKey("key"));   // False
Console.WriteLine(ci.ContainsKey("key"));   // True

2. Обычный класс в роли ключа сравнивается по ссылке. Два объекта с одинаковыми полями для словаря — разные ключи. Тип record генерирует Equals и GetHashCode по значениям полей:

using System;
using System.Collections.Generic;

var byClass = new Dictionary<PointC, string> { [new PointC(1, 2)] = "A" };
var byRecord = new Dictionary<PointR, string> { [new PointR(1, 2)] = "A" };

Console.WriteLine(byClass.ContainsKey(new PointC(1, 2)));   // False
Console.WriteLine(byRecord.ContainsKey(new PointR(1, 2)));  // True

class PointC { public int X, Y; public PointC(int x, int y) { X = x; Y = y; } }
record PointR(int X, int Y);

Если ключом должен быть обычный класс, переопределите в нем Equals и GetHashCode согласованно (равные объекты обязаны давать равный хеш) или передайте в словарь свой IEqualityComparer<T>.

3. Ключ нельзя менять после добавления. Если поле, от которого зависит хеш, изменилось, элемент остается в старой корзине и перестает находиться:

using System;
using System.Collections.Generic;

var key = new MutKey { X = 1 };
var dict = new Dictionary<MutKey, string> { [key] = "A" };
key.X = 2;
Console.WriteLine($"{dict.ContainsKey(key)}, Count={dict.Count}");  // False, Count=1

record MutKey { public int X { get; set; } }

Элемент в словаре есть, но найти его по ключу нельзя. Надежное правило — ключи неизменяемые: строки, числа, record с init-свойствами.

Dictionary и соседние коллекции

Задача Что взять Замечание
обычный поиск по ключу Dictionary<TKey, TValue> порядок не гарантирован
нужен порядок ключей SortedDictionary<TKey, TValue> дерево, операции O(log n)
запись из нескольких потоков ConcurrentDictionary<TKey, TValue> System.Collections.Concurrent
словарь заполнили один раз и только читаете FrozenDictionary<TKey, TValue> .NET 8+, ToFrozenDictionary()
старый код до обобщений Hashtable ключи и значения object, в новом коде не использовать

Про потоки: Dictionary допускает одновременное чтение из нескольких потоков, пока его никто не изменяет. Если хотя бы один поток пишет, нужна блокировка (lock) или ConcurrentDictionary.

Если заранее известно число элементов, передайте емкость в конструктор (new Dictionary<string, int>(10_000)): словарь не будет несколько раз перестраивать внутреннюю таблицу по мере роста.

Если не получилось: симптом -> причина

Симптом Причина Что сделать
KeyNotFoundException чтение dict[key] по отсутствующему ключу TryGetValue или GetValueOrDefault
ArgumentException ... same key Add на существующий ключ индексатор или TryAdd
ArgumentNullException (key) ключ равен null проверить вход до обращения к словарю
InvalidOperationException ... Collection was modified добавление ключей внутри foreach перебирать dict.Keys.ToList()
ключ есть, а ContainsKey дает False регистр строки, класс без Equals, измененный ключ компаратор, record, неизменяемый ключ

Выводы

  • Dictionary<TKey, TValue> — основная реализация словаря в C#: хеш-таблица с поиском по ключу в среднем за O(1).
  • Add бросает исключение на повторном ключе, индексатор перезаписывает, TryAdd оставляет старое значение.
  • Для чтения ключа, которого может не быть, используйте TryGetValue: индексатор в этом случае дает KeyNotFoundException.
  • Порядок перебора не гарантирован, а добавлять ключи внутри foreach по тому же словарю нельзя.
  • Ключ должен корректно реализовывать Equals/GetHashCode и не меняться после добавления.

Где применяется / связь с практикой

Словари встречаются почти в каждом C#-проекте: кеши и справочники, группировка и подсчет, сопоставление идентификатора с объектом, конфигурация. На собеседованиях джунов по C# часто спрашивают как раз про TryGetValue, сложность операций и требования к ключу.

Освойте тему на практике

Коллекции, обобщения и LINQ системно разбирают на курсе «C#-разработчик. Базовый уровень» — с практикой на реальных задачах и ревью кода. Попробовать формат бесплатно можно на открытых уроках Otus.

FAQ

Чем Dictionary отличается от List с поиском через Find?
List<T>.Find проверяет элементы по очереди, это O(n); словарь находит элемент по хешу ключа в среднем за O(1). На сотне элементов разница незаметна, на сотнях тысяч поисков — принципиальна.

Можно ли хранить несколько значений под одним ключом?
Напрямую нет, ключ уникален. Обычный прием — значение-список: Dictionary<string, List<int>>, либо ToLookup из LINQ, если данные после построения не меняются.

Как получить ключ по значению?
Словарь такого поиска не ускоряет: придется перебрать пары (dict.First(p => p.Value == x).Key), и значений может оказаться несколько. Если обратный поиск частый, заведите второй словарь «значение -> ключ».

OTUS Журнал