ValueTuple
이라는 자료형 정리Dictionary
의 key와 GetHashCode()
메서드 트러블슈팅오늘 푼 알고리즘 문제에서 Queue에 (int idx, int priority)
라는 자료형을 넣어봤는데, 잘 실행됐다.
나는 이게 익명 형식 (anonymous type)
인줄 알았는데, 구글링으로 익명 형식의 example을 찾아보니, 사용법이 살짝 달랐다.
그래서 튜터님께 질문하러 갔는데, 튜플이라고 알려주셨다.
프로그래머스 - 프로세스
using System.Collections.Generic;
using System.Linq;
public class Solution
{
public int solution(int[] priorities, int location)
{
int answer = 0;
List<int> pq = new List<int>();
Queue<(int idx, int priority)> q = new Queue<(int idx, int priority)>();
for (int i = 0; i < priorities.Length; i++)
{
pq.Add(priorities[i]);
q.Enqueue((i, priorities[i]));
}
pq = pq.OrderByDescending(x => x).ToList();
while (q.Count > 0)
{
(int idx, int priority) peek = q.Dequeue();
if (pq[0] == peek.priority)
{
pq.RemoveAt(0);
answer++;
if (peek.idx == location)
break;
}
else
q.Enqueue(peek);
}
return answer;
}
}
익명 타입은 var example = new { ... };
처럼 사용한다.
밸류 튜플은 var example = ( ... );
처럼 사용한다.
또, 그냥 Tuple
이라는 자료형도 있는데, 이 녀석은 var example = new ( ... );
처럼 사용한다.
눈에 보이는 차이점부터 정리해보자면, 익명 타입은 중괄호를 사용하고, 두 튜플은 공통적으로 소괄호를 사용한다.
또, 밸류 튜플은 new 키워드가 없고, 나머지 두 자료형엔 new 키워드가 있다는 것으로 밸류 튜플은 값 타입, 나머지 투 자료형은 참조 타입이라는 것을 짐작할 수 있다. 사실 밸류 튜플은 이름부터 값(value)이긴 하다.
공식문서에서 친절하게 표로 비교해준 자료가 있다.
이름 | 액세스 한정자 | 형식 | 사용자 지정 멤버 이름 | 분해 지원 | 식 트리 지원 |
---|---|---|---|---|---|
익명 형식 | internal | class | ✔️ | ❌ | ✔️ |
Tuple | public | class | ❌ | ❌ | ✔️ |
ValueTuple | public | struct | ✔️ | ✔️ | ❌ |
특이한 점은, 익명 형식은 액세스 한정자가 internal
이라서, 같은 어셈블리에선 정의된 하나의 타입
으로 인정되지만, 다른 어셈블리에선 모든 형식이 같은 두 익명 형식이라도 다른 타입으로 판단한다고 한다.
현재 작업 중인 프로젝트에서 ChunkCoord
라는 구조체를 선언해서 딕셔너리의 key로 사용하고 있었는데, 프레임이 갑자기 엄청나게 떨어져서 확인해보니까 딕셔너리에 TryGetValue로 접근할 때, 엄청난 양의 GC가 발생하고 있었다.
/// <summary> 시야범위 내의 청크 생성 </summary>
private void UpdateChunksInViewRange()
{
_prevActiveChunks = _currentActiveChunks;
_currentActiveChunks = new();
for (int x = -WorldData.ViewRange; x < WorldData.ViewRange; x++)
{
for (int z = -WorldData.ViewRange; z < WorldData.ViewRange; z++)
{
if (_chunkMap.TryGetValue(_currentPlayerCoord + new ChunkCoord(x, z), out var currentChunk))
{
_currentActiveChunks.Add(currentChunk);
currentChunk.SetActive(true);
_prevActiveChunks.Remove(currentChunk);
}
}
}
foreach (var chunk in _prevActiveChunks)
chunk.IsActive = false;
}
처음엔 도저히 이해가 안됐다.
몇 십분 전까지만 해도 GC 없이 잘 동작했던 코드였고, 디버깅을 해보니 list를 생성, 삭제하는 곳에서 GC가 발생하는 것도 아니고 딕셔너리 접근하는 것에서 GC가 발생하고 있었기 때문이다.
진짜 문제는 ChunkCoord
구조체의 GetHashCode()
메서드를 잘못 오버라이딩해서 발생한 것이었다.
=연산자 오버로딩과 Equals() 메서드 오버로딩이 필요해서 오버로딩을 하고 나니까, GetHashCode() 메서드도 재정의하라고 IDE에서 띄워주길래, 구조체는 값 형식이니까 그냥 0 리턴해주면 되겠지. 라는 안일한 생각 때문에 발생했다.
public struct ChunkCoord
{
public int x;
public int z;
public ChunkCoord(int x, int z)
{
this.x = x;
this.z = z;
}
public override int GetHashCode()
{
return 0; // ★
}
...
}
HashSet
, Dictionary
등등 해시를 이용하는 제네릭 컬렉션에서는 객체를 비교할 때 GetHashCode()
를 이용하여 비교한 후, 같은 해시를 가졌다면 Equals()
까지 체크한다.
또, 값 타입인 구조체는 Dictionary 선언 시, IEqualityComparer<T>
를 생성자에 전달해주지 않았다면 접근마다 최소 한 번의 박싱이 일어나는데, 이 박싱은 곧 GC를 발생시킨다.
위와 같이 해버리면, 모든 객체가 0이라는 HashCode를 갖게 되고, 딕셔너리는 매 프레임 수십 수백 번의 비교마다 딕셔너리 내부의 모든 key가 전부 같다고 판단하는 것 같다.
내 경우엔 매 프레임 400번 딕셔너리에 접근하고, 딕셔너리에도 약 400개의 원소가 들어있으니 대략 16만번 딕셔너리 접근이 발생하니깐 위의 엄청난 양의 GC call이 드디어 이해가 간다...
ChunkCoord
구조체에 IEqualityComparer<ChunkCoord>
인터페이스를 구현해주고, 딕셔너리 생성자에 comparer로 자기자신을 넘겨줬다.
사실 comparer는 저렇게 넘겨주는게 맞긴한가 싶기도 하고, 넘겨주나 안넘겨주나 인터페이스를 구현해놔서 그런지 잘 동작하긴 한다.
World.cs
private Dictionary<ChunkCoord, Chunk> _chunkMap = new(comparer: new ChunkCoord());
ChunkCoord.cs
public struct ChunkCoord : IEqualityComparer<ChunkCoord>
{
public int x;
public int z;
public ChunkCoord(int x, int z)
{
this.x = x;
this.z = z;
}
public override string ToString()
{
return $"({x}, {z})";
}
public override int GetHashCode()
{
return HashCode.Combine(x, z);
}
public int GetHashCode(ChunkCoord obj)
{
return obj.GetHashCode();
}
...
}
C# 의 타입 - 4. 객체의 식별 (Equals, GetHashCode)
GC에게 먹이를 주지 마시오
C# Equals()과 GetHashCode()를 함께 재정의 해야하는 이유