Словарь в C# — это коллекция пар «ключ — значение», в которой значение находят по уникальному ключу. Основная реализация — обобщенный класс Dictionary<TKey, TValue> из пространства имен System.Collections.Generic, внутри это хеш-таблица: поиск, добавление и удаление по ключу в среднем выполняются за O(1), если хеш-функция ключа распределяет значения равномерно.
Содержание
- Минимальный пример: создать, добавить, найти, перебрать
- Добавление: Add, индексатор или TryAdd
- Чтение по ключу: индексатор, TryGetValue, GetValueOrDefault
- Перебор словаря и изменение во время foreach
- Практическая задача: подсчет слов
- Какие ключи подходят: GetHashCode, Equals и компаратор
- Dictionary и соседние коллекции
- Если не получилось: симптом -> причина
- Выводы
- Где применяется / связь с практикой
- FAQ
Ниже — рабочий пример целиком, затем разбор операций: как добавлять (и чем 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), и значений может оказаться несколько. Если обратный поиск частый, заведите второй словарь «значение -> ключ».



