구조체와 연산자 오버로딩

이정현
∙∙조회 1

20261022 구조체와 연산자 오버로딩

개요

연산자 오버로딩은 클래스나 구조체등 만든 타입에 +, -, ==같은 연산자를 붙이는 문법임. 좌표를 더할 때 필드를 하나씩 더하는 대신 a + b라고 쓸 수 있게 됨.

좌표, 색, 금액처럼 값처럼 다루고 싶은 작은 데이터 묶음이 구조체로 만들기에 알맞은 대상이고, 그런 것들이 연산자를 붙이면 자연스러워지는 대상이기도 함. Unity의 Vector3가 구조체이고 v1 + v2, v * 2가 오버로딩된 연산자인 것도 같은 이유이다.

값 타입과 참조 타입

변수가 담고 있는 것

C#의 모든 타입은 둘 중 하나임.

분류변수가 담는 것예
값 타입내용 자체int, double, bool, char, decimal, 구조체, 열거형
참조 타입내용이 놓인 자리클래스, 배열, string, 인터페이스

이 둘의 차이는 대입할 때와 함수에 넘길 때 껕으로 드러남.

대입할 때

같은 모양의 데이터를 클래스와 구조체로 각각 만들어 대입해 보면 차이가 보임.

csharp
// 클래스(참조 타입)와 구조체(값 타입)의 차이 예시
PointClass c1 = new PointClass();
c1.X = 10;

PointClass c2 = c1;      // 가리키는 자리가 복사됨
c2.X = 99;

Console.WriteLine($"클래스  c1.X = {c1.X}, c2.X = {c2.X}");

PointStruct s1 = new PointStruct();
s1.X = 10;

PointStruct s2 = s1;     // 내용이 복사됨
s2.X = 99;

Console.WriteLine($"구조체  s1.X = {s1.X}, s2.X = {s2.X}");

class PointClass
{
    public int X;
}

struct PointStruct
{
    public int X;
}

클래스 쪽은 c2를 고쳤는데 c1도 함께 바뀌었음. 둘이 같은 객체를 보고 있기 때문임. 구조체 쪽은 s1이 그대로임. s2가 별개의 복사본이기 때문임.

함수에 넘길 때

매개변수도 대입과 같은 규칙을 따름

csharp
// 매개변수일 때 클래스(참조 타입)와 구조체(값 타입)의 차이 예시
PointClass c = new PointClass();
c.X = 10;
ChangeClass(c);
Console.WriteLine($"클래스  {c.X}");

PointStruct s = new PointStruct();
s.X = 10;
ChangeStruct(s);
Console.WriteLine($"구조체  {s.X}");

void ChangeClass(PointClass p)
{
    p.X = 99;
}

void ChangeStruct(PointStruct p)
{
    p.X = 99;
}

class PointClass
{
    public int X;
}

struct PointStruct
{
    public int X;
}

구조체를 넘기면 함수 안에서 아무리 고쳐도 원본이 그대로임. 함수가 받은 것이 복사본이기 때문이다.구조체를 함수 내에서 변경하려면 ref를 사용해야 한다.

배열의 요소

배열은 찹조 타입이지만, 요소의 타입이 값 타입인지 참조 타입인지에 따라 배열을 만든 직후의 상태가 다름.

csharp
// 배열의 요소일 때 클래스(참조 타입)와 구조체(값 타입)의 차이 예시
// 구조체 배열 — 요소가 이미 자리를 잡고 있음
PointStruct[] structs = new PointStruct[3];
structs[0].X = 7;
Console.WriteLine($"구조체 배열  {structs[0].X}, {structs[1].X}");

// 클래스 배열 — 요소가 아직 아무것도 가리키지 않음
PointClass[] classes = new PointClass[3];
Console.WriteLine($"클래스 배열  {(classes[0] == null ? "빈 자리" : "객체 있음")}");

classes[0] = new PointClass();
classes[0].X = 7;
Console.WriteLine($"클래스 배열  {classes[0].X}");

struct PointStruct
{
    public int X;
}

class PointClass
{
    public int X;
}

구조체 배열은 new로 배열을 만든 순간 요소마다 값이 자리를 잡음. 그래서 바로 structs[0].X = 7이 됨. 클래스 배열의 요소는 처음에 null이므로, 접근할 칸에 새 객체나 기존 객체의 참조를 넣어야 함.

저장되는 곳

저장 위치는 타입의 분류만으로 결정되지 않음. 아래 그림은 일반적인 지역 변수와 클래스 객체를 설명하기 위한 모형임. 실제 지역 변수의 배치는 런타임 최적화에 따라 달라질 수 있으며, 클래스 안에 선언한 구조체 필드는 그 객체 안에 저장됨.

분류놓이는 곳정리되는 시점
값 타입지역 변수, 배열 요소, 객체 필드 등이 담긴 자리저장된 자리의 수명을 따름
클래스 객체관리되는 힘(참조 변수의 위치와 구분)도달할 수 없게 되면 GC 수집 대상이 되며 시점은 정해져 있지 않음
meraind
flowchart LR
    subgraph 스택
        S1["s1 : PointStruct X = 10"]
        S2["c1 : 참조"]
    end

    subgraph 힙
        H1["PointClass X = 99"]
    end

    S2 --> H1

구조체는 값을 담는 자리마다 별도 클래스 객체를 만들지 않고 데이터를 직접 저장할 수 있음. 다만 복사 크기와 박싱 여부에 따라 비용이 달라지므로 값 타입이라는 이유만으로 빠르다고 단정하지 않음. 단, 값 타입이 항상 스택에 놓이는 것은 아님. 클래스의 필드로 들어간 구조체는 그 클래스 객체와 함께 힙에 놓임. 담긴 자리를 따라가는 것임.

구조체 만들기

struct키워드

class자리에 struct를 쓰면 됨. 나머지 표기는 거의 같음.

csharp
// 구조체 선언 예시
Point p = new Point();
p.X = 100;
p.Y = 200;

struct Point
{
    public int X;
    public int Y;
}

new없이 선언하기

구조체는 값 타입이므로 변수를 선언하는 것 만으로 자리가 잡힘. new를 쓰지 않아도 됨.

csharp
// new 없이 선언하는 예시
Point p;
p.X = 100;
p.Y = 200;

Console.WriteLine($"({p.X}, {p.Y})");

struct Point
{
    public int X;
    public int Y;
}

클래스 지역 변수는 선언만 하면 아직 값이 할당되지 않은 상태임. 사용 전에 새 객체나 기존 객체의 참조를 대입해야 하며, 자동으로 null이 대입되는 필드, 배열요소와 구분함. 두 표기(new 사용 여부)의 차이는 필드가 채워지는가에 있음

표기결과
Point p = new Point();모든 필드가 기본값(int는 0)으로 체워짐
Point p필드가 아직 채워지지 않은 상태

채우지 않은 필드는 읽을 수 없음

new없이 선언한 구조체의 필드를 값을 넣기 전에 읽으면 컴파일 오류임. 읽기 전에 값을 넣거나, new Point()로 기본값을 채우면 해결됨.

생성자

구조체도 생성자를 가질 수 있음. 방법은 클래스와 같음

csharp
// 구조체 생성자 사용 예시
struct Point
{
    public int X;
    public int Y;

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
}

생성자는 모든 필드를 채워야 함

구조체 생성자는 필드를 하나도 남기지 않고 채워야 함. 하나라도 빠지면 컴파일 오류임. 빠진 필드에 기본값을 명시해 주면 됨.

csharp
// 구조체 생성자중 빠진 필드에 값을 넣어주는 예시
struct Point
{
    public int X;
    public int Y;

    public Point(int x)
    {
        X = x;
        Y = 0;          // 남는 필드도 채움
    }
}

메서드와 프로퍼티

구조체에도 메서드, 프로퍼티를 넣을 수 있음. 클래스와 표기가 같음.

ToString() 재정의

구조체도 object의 멤버를 가지고 있으므로 ToString()을 재정의할 수 있음. 좌표나 금액처럼 출력할 일이 많은 타입에서 특히 유용함.

csharp
// ToString() 재정의 예시
Point p = new Point(3, 7);

Console.WriteLine(p);
Console.WriteLine($"위치는 {p}");

struct Point
{
    public int X;
    public int Y;

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public override string ToString()
    {
        return $"({X}, {Y})";
    }
}

재정의 하지 않으면 타입 이름이 그대로 나옴.

필드를 묶는 실제 예

낱개 변수로 관리하던 것을 구조체로 묶으면 배열로 다루기가 쉬워짐.

csharp
// 구조체로 묶는 예시
Score[] scores =
{
    new Score("홍길동", 100, 90),
    new Score("백두산", 90, 80),
    new Score("임꺽정", 80, 70)
};

Console.WriteLine("이름     국어  영어  총점  평균");

foreach (Score s in scores)
{
    Console.WriteLine($"{s.Name}  {s.Korean,4}  {s.English,4}  {s.Total,4}  {s.Average,5:F1}");
}

struct Score
{
    public string Name;
    public int Korean;
    public int English;

    public Score(string name, int korean, int english)
    {
        Name = name;
        Korean = korean;
        English = english;
    }

    public int Total => Korean + English;
    public double Average => Total / 2.0;
}

구조체 안에 구조체를 넣을 수도 있음.

구조체와 클래스 중 무엇을 쓸지

구조체는 다른 구조체나 클래스를 물려받을 수 없음. 인터페이스만 구현이 가능하기 때문에 콜론 뒤에 타입을 적으면 인터페이스로만 해석됨. 표기는 클래스와 같음.

차이 정리

항목구조체(struct)클래스(class)
분류값 타입참조 타입
대입내용이 복사됨가리키는 자리가 복사됨
선언만 하면자리가 잡힘(new생략 가능)아무것도 가리키지 않음
배열을 만들면요소가 기본값으로 초기화됨요소가 null이며 사용 전에 객체 참조 필요
null담을 수 없음담을 수 있음
상속안 됨됨
인터페이스 구현됨됨
생성자모든 필드를 채워야 함일부만 채워도 됨
놓이는 곳담긴 자리를 따라감객체는 관리되는 힘

선택 기준

기본값은 클래스임. 구조체는 아래 조건을 대체로 만족할 때 고름.

조건이유
데이터가 작음(필드 몇 개)복사 비용이 작아야 함
값처럼 다루는 것이 자연스러움좌표, 색, 금액, 날짜
만들어진 뒤 내용이 바뀌지 않음복사되어 돌아다녀도 혼란이 없음
상속이 필요하지 않음구조체는 상속이 안 됨

큰 구조체는 값 복사 비용이 커질 수 있음. 클래스 상속이 필요한 설계에는 클래스를 씀. 구조체도 인터페이스를 구현할 수 있으며, null을 표현하는 Nullable 값 타입도 별도로 있음. 구조체 자체의 상태 변경을 제한하려면 readonly를 타입 앞에 붙임. 인스턴스 필드도 모두 readonly여야 함. 참조 필드가 가리키는 객체 내부까지 불변이 되는 것은 아님.

박싱과 object

값 타입과 참조 타입은 변수가 담는 것이 서로 다름. 그런데 상속의 최상위에는 object가 있고 object는 참조 타입임. 그래서 값 타입을 object 변수에 담아야 하는 경우가 생김.

담을 수는 있음. 다만 그 자리에서 값을 힙으로 옮겨 담는 일이 벌어짐. 이것을 박싱(boxing)이라고 함.

Mermaid
렌더링 중...

꺼낼 때는 반대로 힙의 값을 값 타입 변수로 복사해 옴. 이것이 언박싱(unboxing) 임.

csharp
// 박싱과 언박싱이 발생하는 예시
Point p = new Point(3, 4);

object boxed = p;              // 박싱 — 힙에 복사본이 만들어짐
Console.WriteLine(boxed);

Point back = (Point)boxed;     // 언박싱 — 타입을 적어 값을 꺼냄
Console.WriteLine(back.X + back.Y);

struct Point
{
    public int X;
    public int Y;

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public override string ToString()
    {
        return $"({X}, {Y})";
    }
}

Console.WriteLine(boxed)가 (3, 4)를 낸 것은 박싱된 객체가 ToString() 재정의를 그대로 갖고 있기 때문임. 감싸인 것이지 다른 타입이 된 것이 아님. int·double·bool·열거형도 값 타입이므로 똑같이 박싱됨.

꺼낼 때는 타입을 적어야 함

박싱은 표기 없이 되지만, 언박싱은 타입을 적어야 함. object에는 무엇이든 들어갈 수 있으므로 컴파일러는 안에 든 것이 무엇인지 알지 못함. 무엇을 꺼낼지는 코드를 쓰는 쪽이 밝혀야 함.

박싱된 것은 복사본임

박싱은 복사임. 힙에 만들어진 객체는 그 순간의 값을 갖고, 원본과 이어져 있지 않음. 대입할 때 내용이 복사되던 것과 같은 일이 object에 담을 때도 일어남. 클래스였다면 둘이 같은 객체를 보므로 boxed 쪽도 함께 바뀜.

눈에 띄지 않는 박싱

object boxed = p;처럼 object라는 글자가 보이는 자리는 알아채기 쉬움. 문제는 그 글자가 코드에 없는데도 박싱이 일어나는 자리임. 매개변수가 object인 메서드에 값 타입을 넘기면 그 자리에서 박싱됨. object[] 배열의 요소에 넣을 때도 마찬가지임. 문자열은 이미 참조 타입이므로 박싱이 일어나지 않음. 값 타입만 해당함. 인터페이스 변수에 담을 때도 박싱됨. 인터페이스 변수 역시 힙의 객체를 가리키는 자리이기 때문임. 이쪽은 결과가 눈에 보이게 어긋나므로 더 위험함.

  • 주의사항: 내용이 바뀌는 구조체는 이렇게 원본과 복사본이 갈라짐. 구조체를 고를 조건에 만들어진 뒤 내용이 바뀌지 않음이 들어 있는 이유가 여기에 있음.

타입이 맞지 않으면 실행 중에 예외가 남

언박싱할 때 적는 타입은 담을 때의 타입과 정확히 같아야 함. 값을 넓히는 변환이라도 통하지 않음. int 값을 long 변수에 대입하는 것은 원래 표기 없이도 되는 변환임. 그런데 박싱된 것을 꺼낼 때는 통하지 않음. 언박싱은 변환이 아니라 감싼 것을 벗기는 일이므로, 안에 든 것이 무엇인지가 그대로 맞아야 함. 담을 때의 타입으로 먼저 꺼내고, 변환은 그 다음에 함. 컴파일러가 막아 주지 않는다는 점이 중요함. (long)boxed는 문법으로 성립하므로 빌드가 통과하고, 그 줄을 실행하는 순간에야 예외가 남.

비용과 피하는 방법

박싱 한 번은 힙 객체 하나임. 값 하나를 담고 꺼내는 사이에 객체를 만들고 값을 두 번 복사함 - 넣을 때 한 번, 꺼낼 때 한 번. 한 번은 사소하지만 반복문 안에 들어가면 반복할 때마다 쌓임. 출력이 같다는 것이 함정임. 두 코드는 결과가 같으므로 출력만 보고는 차이를 알 수 없음. 차이는 앞쪽이 힙에 객체 1,000개를 만들어 버렸다는 데 있고, 그것은 출력에 드러나지 않음. 피하는 방법은 하나로 요약됨 - 값 타입을 object나 인터페이스에 담지 않는 것임. 값 타입 전용 오버로드를 만들어 두면 그 타입으로 부를 때 object 자리를 거치지 않음. Console.WriteLine()에 int, double, bool용 오버로드가 따로 있는 것도 같은 이유임. 값을 그대로 받는 오버로드가 있으면 박싱이 일어나지 않음.

연산자 오버로딩

필드를 하나씩 더하는 코드

좌표를 더하는 코드를 그냥 쓰면 필드가 셋이면 세 번, 넷이면 네 번 적어야 함. 식이 길어지면 어느 필드를 빠뜨렸는지 알아보기도 어려워짐. 연산자 오버로딩은 이 코드를 a + b로 줄임

operator 키워드

메서드를 정의하듯 쓰는데, 이름 자리에 operator와 연산자 기호를 적음.

csharp
// operator 사용하여 +를 재정의하고 override로 ToString()을 재정의 하는 예시
struct Point
{
    public int X;
    public int Y;

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    // + 연산자 정의
    public static Point operator +(Point left, Point right)
    {
        return new Point(left.X + right.X, left.Y + right.Y);
    }

    public override string ToString()
    {
        return $"({X}, {Y})";
    }
}

a + b를 쓰면 컴파일러가 이 메서드를 부름. 두 피연산자가 각각 left,right로 들어감.

정의 규칙

규칙내용
public static필수객체 하나에 속한 동작이 아니라 타입에 속한 동작임
매개변수 중 하나는 그 타입다른 타입끼리의 연산을 정의할 수는 없음
반환 타입+는 피연산자와 다른 타입도 가능하며, 연산자마다 제약이 다름
매개변수 개수가 정해짐이항 연산자는 2개, 단항 연산자는 1개

static이 없으면 컴파일 되지 않음.

이항 연산자

두 피연산자를 받는 연산자임. 오버로드할 수 있는 것은 다음과 같음.

연산자뜻
+``-``*``/``%산술
&``|``^비트 논리
<<``>>시프트
==``!=``<``>``<=``>=비교
  • 참고사항으로 Point * int처럼 하나를 정의해도 int * Point가 자동으로 되지는 않음. 순서를 바꿔 쓸 수 있게 하려면 다음과 같이 둘 다 정의해야 함
csharp
// Point * int
public static Point operator *(Point point, int factor)
{
    return new Point(point.X * factor, point.Y * factor);
}

// int * Point — 순서를 바꿔 쓸 수 있게 따로 정의함
public static Point operator *(int factor, Point point)
{
    return point * factor;
}

단항 연산자

하나의 피연산자를 받는 연산자임.

연산자뜻
+``-부호
!논리 부정
~비트 보수
++``--증감
true``false참, 거짓 판정

++를 정의하면 c++와 ++c 양쪽이 동작함. 원본을 고치지 않고 새 값을 돌려주는 것이 규칙임 - c++의 결과를 대입하는 일을 컴파일러가 알아서 처리함.

비교 연산자는 쌍으로

구조체는 기본적으로 ==로 비교할 수 없음. ==를 정의하려 하면 !=도 함께 요구됨.

짝지어 정의해야 하는 것이 셋 있음.

짝연산자
같음 판정==와!=
대소 비교<와>
이상 이하<=와>=

한쪽만 있으면 예를 들어 a > b는 되는데 a < b는 안되는 이상한 타입이 되므로 컴파일러가 막는 것임.

Equals와 GetHashCode

==와 !=를 정의하면 경고가 둘 남음. ==는 바꿨는데 Equals()는 그대로 두면, 같은 두 값을 비교하는 방법이 두 가지로 갈려 결과가 어긋날 수 있음. 그래서 함께 재정의함. HashCode.Combine()은 여러 필드를 하나의 해시값으로 섞어 주는 도우미임. 필드를 그대로 넘기면 됨.

복합 대입은 따라옴

+=,-= 같은 복합 대입은 별도로 정의할 수 없음. 그리고 정의할 필요도 없음. +를 정의하면 +=가 자동으로 동작함. p += q가 p = p + q로 처리되기 때문임.

오버로드할 수 없는 연산자

연산자대안
=대입없음
.멤버 접근없음
?:삼항없음
()메서드 호출없음
[]인덱스 접근인덱서로 만듦
&&``||&``|와true,false를 정의하면 따라옴
+=``-=등 복합 대입대응하는 이항 연산자를 정의하면 따라옴

형 변환 연산자

실행 결과

yaml
a == b : True
a != c : True
a.Equals(b) : True
해시가 같은가 : True

HashCode.Combine()은 여러 필드를 하나의 해시값으로 섞어 주는 도우미임. 필드를 그대로 넘기면 됨.

복합 대입은 따라옴

+=·-= 같은 복합 대입은 별도로 정의할 수 없음. 그리고 정의할 필요도 없음 — +를 정의하면 +=가 자동으로 동작함.

csharp
using System;

Point p = new Point(1, 2);
Console.WriteLine(p);

p += new Point(3, 4);        // + 만 정의했는데 += 가 동작함
Console.WriteLine(p);

p += new Point(10, 10);
Console.WriteLine(p);

struct Point
{
    public int X;
    public int Y;

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public static Point operator +(Point left, Point right)
    {
        return new Point(left.X + right.X, left.Y + right.Y);
    }

    public override string ToString()
    {
        return $"({X}, {Y})";
    }
}

실행 결과

scss
(1, 2)
(4, 6)
(14, 16)

p += q가 p = p + q로 처리되기 때문임.

오버로드할 수 없는 연산자

연산자대안
= 대입없음
. 멤버 접근없음
?: 삼항없음
() 메서드 호출없음
[] 인덱스 접근인덱서로 만듦
&& ||&·|와 true·false를 정의하면 따라옴
+= -= 등 복합 대입대응하는 이항 연산자를 정의하면 따라옴

형 변환 연산자

타입 사이의 변환도 연산자로 정의할 수 있음. implicit과 explicit 두 가지가 있음.

implicit 표기 없이 변환

값을 잃지 않고 항상 성공하는 변환에 씀. 변환 표기를 적지 않아도 컴파일러가 알아서 바꿈.

csharp
// implicit 사용 예시
struct Celsius
{
    public double Degrees;

    public Celsius(double degrees)
    {
        Degrees = degrees;
    }

    // Celsius → double
    public static implicit operator double(Celsius celsius)
    {
        return celsius.Degrees;
    }

    // double → Celsius
    public static implicit operator Celsius(double degrees)
    {
        return new Celsius(degrees);
    }
}

explicit 표기를 적어야 변환

값이 달라지거나 뜻이 바뀌는 변환에 씀. 괄호로 타입을 적어야 변환됨.

csharp
// explicit 사용 예시
struct Celsius
{
    public double Degrees;

    public Celsius(double degrees)
    {
        Degrees = degrees;
    }

    // Celsius → Fahrenheit
    public static explicit operator Fahrenheit(Celsius celsius)
    {
        return new Fahrenheit(celsius.Degrees * 9 / 5 + 32);
    }

    // Fahrenheit → Celsius
    public static explicit operator Celsius(Fahrenheit fahrenheit)
    {
        return new Celsius((fahrenheit.Degrees - 32) * 5 / 9);
    }

    public override string ToString()
    {
        return $"{Degrees}°C";
    }
}

어느 쪽을 쓸지

상황선택
항상 성공하고 값을 잃지 않음implicit
값이 달라지거나 뜻이 바뀜explicit
판단이 서지 않음explicit

explicit이 안전한 선택임. 코드에 변환 표기가 남으므로 읽는 사람이 변환이 일어난다는 것을 알 수 있음. implicit은 표기가 없어 편하지만, 의도하지 않은 자리에서 조용히 변환되어 버그를 만들 수 있음.

오버로딩의 원칙

기호가 뜻하는 대로 동작할 것

+는 더하는 것이어야 함. 코드를 읽는 사람은 기호를 보고 뜻을 짐작하므로, 기호와 동작이 어긋나면 읽을 수 없는 코드가 됨.

csharp
// 잘못 사용하는 예시. 이렇게 쓰지 않음 — 기호는 + 인데 동작은 뺄셈임
public static Point operator +(Point left, Point right)
{
    return new Point(left.X - right.X, left.Y - right.Y);
}

원본을 바꾸지 말 것

연산자는 새 값을 돌려주고 피연산자를 건드리지 않아야 함. 구조체는 값이 복사되어 들어오므로 안에서 고쳐도 원본에 영향은 없지만, 그렇게 쓰면 읽는 사람이 헷갈림.

csharp
// 새 값을 만들어 돌려주는 예시
public static Point operator +(Point left, Point right)
{
    return new Point(left.X + right.X, left.Y + right.Y);
}

관련된 것을 함께 정의할 것

한쪽만 정의하면 쓰는 쪽에서 되는 것과 안 되는 것을 외워야 함.

정의했으면함께 정의할 것
+-
==!=, Equals(), GetHashCode()
<>, 그리고 대개 <=, >=
Point * intint * Point