컬렉션 심화
Dictionary · HashSet · Queue · Stack 을 선택해 사용합니다.
컬렉션마다 잘하는 일이 다릅니다
1.7에서 배열과
List<T> 를 다뤘습니다. 순서대로 담고 순서대로 꺼내는
데는 그것으로 충분합니다. 그런데 요구가 조금만 달라지면 금세 불편해집니다.
- 이름으로 값을 바로 찾고 싶다 — 목록은 앞에서부터 하나씩 비교해야 합니다.
- 같은 것을 두 번 담지 않고 싶다 — 담을 때마다 이미 있는지 확인해야 합니다.
- 먼저 온 것을 먼저 처리하거나, 마지막 것을 먼저 되돌리고 싶다.
이 요구마다 그 일을 잘하도록 만들어진 컬렉션이 따로 있습니다.
Dictionary·HashSet·Queue·Stack
입니다. 전부 3.1에서 본 제네릭 형식이라
담을 형식을 사용하는 쪽이 정합니다.
선택을 잘못해도 코드는 동작합니다. 다만 자료가 늘어날 때 느려지거나, 없어야 할 중복을 손으로 막게 됩니다. 그래서 이 단원은 문법보다 선택 기준에 무게를 둡니다.
이름으로 값 찾기
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
// 키(이름) 로 값(가격) 을 찾는 컬렉션입니다 var prices = new Dictionary<string, int> { ["사과"] = 1500, ["바나나"] = 3000, ["포도"] = 8000 }; Console.WriteLine(prices["사과"]); Console.WriteLine(prices.Count); // 없을 수도 있는 키는 TryGetValue 로 물어봅니다 if (prices.TryGetValue("수박", out var price)) Console.WriteLine($"수박은 {price}원"); else Console.WriteLine("수박은 없습니다"); // 키와 값을 함께 꺼내며 탐색합니다 foreach (var (name, won) in prices) Console.WriteLine($"{name} : {won}원");
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
Dictionary<TKey, TValue> 는 형식 매개 변수를 둘 받습니다 —
찾을 때 사용할 키와 담을 값입니다. 위에서는
string 으로 찾아 int 를 꺼냅니다.
목록으로 찾을 때와 무엇이 다른가
var items = new List<Product> { … }; // 앞에서부터 하나씩 비교합니다 var found = items .FirstOrDefault(x => x.Name == "사과"); // 없으면 null 입니다 if (found is not null) Console.WriteLine(found.Price); // 담을 때 중복도 막지 않습니다
var prices = new Dictionary<string, int> { … }; // 키를 셈해 자리를 바로 찾습니다 if (prices.TryGetValue("사과", out var price)) Console.WriteLine(price); // 같은 키는 하나뿐입니다 prices["사과"] = 1600; // 덮어씁니다
Dictionary 는 키를 해시라는 숫자로 셈해
자리를 바로 계산합니다. 담긴 것이 몇 개든 비교 횟수가 늘어나지
않습니다. 목록은 앞에서부터 하나씩 비교하므로 자료가 늘어난 만큼 느려집니다.
없는 키를 물어볼 때
[] 로 꺼내는 것은 있다고 확신할 때만
사용하세요. 없으면 예외가 발생합니다.
Console.WriteLine(prices["수박"]); // KeyNotFoundException // ① 물어보고 꺼내기 — 두 번 찾습니다 if (prices.ContainsKey("수박")) Console.WriteLine(prices["수박"]); // ② TryGetValue — 한 번에 끝냅니다(권장) if (prices.TryGetValue("수박", out var price)) Console.WriteLine(price);
②를 권합니다. ①은 같은 키를 두 번 찾습니다 — 있는지 한 번, 꺼내며 한
번입니다. TryGetValue 는 찾은 값을
out 매개 변수로 함께 내주므로 한 번이면 됩니다. 없으면
false 를 돌려주고 price 에는
그 형식의 기본값(int 면 0)이
담깁니다.
out var price 라고 적으면 받을 변수를 그 자리에서
만듭니다(C# 7.0). 그 전에는 int price; 를 위에
따로 선언해야 했습니다.
중복을 담지 않는 HashSet
같은 값을 두 번 담지 않아야 한다면 HashSet<T> 입니다.
담는 쪽에서 확인할 필요가 없습니다 — 컬렉션이 알아서 막습니다.
var tags = new HashSet<string> { "C#", "닷넷", "C#" }; Console.WriteLine(tags.Count); // 2 — 담을 때 이미 걸러졌습니다 Console.WriteLine(tags.Add("웹")); // True — 새로 담겼습니다 Console.WriteLine(tags.Add("C#")); // False — 이미 있어 담기지 않았습니다 Console.WriteLine(string.Join(", ", tags)); // C#, 닷넷, 웹
Add 가 bool 을 돌려주는 것이
쓸모 있습니다 — 처음 보는 것일 때만 무언가 하려면 그 값을 그대로
if 에 넣으면 됩니다. 다만 순서를 지켜 주지
않습니다. 순서가 필요하면 목록을 함께 두어야 합니다.
먼저 온 것을 먼저 — Queue
먼저 넣은 것이 먼저 나옵니다(FIFO). 처리 대기열, 보낼 메일 목록처럼 받은 순서를 지켜야 하는 작업에 사용합니다.
var waiting = new Queue<string>(); waiting.Enqueue("첫 번째"); // 넣기 waiting.Enqueue("두 번째"); waiting.Enqueue("세 번째"); Console.WriteLine(waiting.Dequeue()); // 첫 번째 — 꺼내면서 지웁니다 Console.WriteLine(waiting.Peek()); // 두 번째 — 보기만 합니다 Console.WriteLine(waiting.Count); // 2
나중 것을 먼저 — Stack
마지막에 넣은 것이 먼저 나옵니다(LIFO). 되돌리기(실행 취소), 뒤로 가기, 괄호 짝 맞추기처럼 가장 최근 것부터 처리해야 하는 작업에 맞습니다.
var history = new Stack<string>(); history.Push("첫 화면"); // 넣기 history.Push("목록"); history.Push("상세"); Console.WriteLine(history.Pop()); // 상세 — 마지막 것이 먼저 Console.WriteLine(history.Peek()); // 목록
Queue 와 Stack 은 이름만 다를 뿐
꺼내는 순서 하나가 다릅니다. 둘 다 비어 있을 때
Dequeue·Pop 을 부르면 예외가
발생하므로, Count 를 먼저 보거나
TryDequeue·TryPop 을 사용합니다.
무엇을 선택할까
| 하고 싶은 일 | 선택 | 기억할 것 |
|---|---|---|
| 순서대로 담고 번호로 꺼내기 | List<T> | 찾기는 앞에서부터 하나씩 |
| 키로 값을 바로 찾기 | Dictionary<TKey, TValue> | 키는 하나뿐, 순서 없음 |
| 중복 없이 모으기 · 있는지만 확인 | HashSet<T> | 순서 없음 |
| 먼저 온 것부터 처리 | Queue<T> | Enqueue · Dequeue |
| 마지막 것부터 되돌리기 | Stack<T> | Push · Pop |
얼마나 차이가 나는가
10만 개를 담아 두고 맨 뒤에 있는 값을 1만 번 찾아 봤습니다.
var list = new List<int>(Enumerable.Range(0, 100_000)); var set = new HashSet<int>(list); for (var i = 0; i < 10_000; i++) list.Contains(99_999); for (var i = 0; i < 10_000; i++) set.Contains(99_999); // List<int>.Contains : 116 ms // HashSet<int>.Contains : 0.01 ms
맨 뒤의 값을 찾은 것이라 목록에는 가장 불리한 조건입니다. 앞쪽에
있는 값이었다면 차이가 훨씬 작습니다. 그래도 방향은 분명합니다 — 목록은 담긴 수만큼
비교하고, HashSet 은 몇 개가 담겼든 한 번에 자리를
계산합니다. 정확한 수치는 컴퓨터마다 다르므로 비율이 아니라 성질을
기억하세요.
물론 열 개짜리 목록에서 찾는 것이라면 어느 쪽이든 상관없습니다. 담기는 수가 늘어날 자리인지가 선택의 기준입니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 2.0Dictionary · HashSet · Queue · Stack (제네릭 컬렉션)
- 7.0out var — 받을 변수를 그 자리에서 선언
이 컬렉션들은 3.1의 제네릭과 함께
C# 2.0에서 도입되었습니다. 그 전에는 Hashtable 처럼
object 로 담는 것뿐이라 꺼낼 때마다 캐스트가
필요했습니다. C# 7.0의 out var 는
TryGetValue 를 짧게 적게 해 준 문법으로,
이 컬렉션들을 다루는 코드의 모양을 크게 바꿨습니다.
직접 해보기
단어 배열을 받아 각 단어가 몇 번 나왔는지 세어 출력해보세요.
Dictionary<string, int> 를 사용합니다.
문자열에서 ( 와 ) 의 짝이
맞는지 확인하는 메서드를 만들어보세요. "(a(b)c)" 는
True, "(a))" 는
False 입니다. 어느 컬렉션이 맞을지 먼저 생각해보세요.
Dictionary는 키로 바로 찾습니다. 담긴 수가 늘어도 비교 횟수가 늘지 않습니다.- 없을 수 있는 키는
[]가 아니라TryGetValue로 물어봅니다.ContainsKey와 함께 사용하면 두 번 찾습니다. HashSet은 중복을 컬렉션이 막습니다.Add가 새로 담겼는지를 돌려줍니다.Queue는 먼저 온 것부터,Stack은 마지막 것부터 꺼냅니다.- 담기는 수가 늘어날 자리인지가 선택의 기준입니다. 열 개짜리라면 무엇이든 상관없습니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.