본문 바로가기

C#

2023.09.11 인터페이스를 이용한 콜백 구현 ~ ref...ing

인터페이스를 이용한 콜백 구현

interface ISource
    {
        int GetResult();
    }

    class Source : ISource
    {
        public int GetResult() { return 10; }

        public void Test()
        {
            Target target = new Target();
            target.Do(this);
        }
    }

    class Target
    {
        public void Do(ISource obj) // Source 타입이 아닌 ISource 인터페이스를 받는다.
        {
            Console.WriteLine(obj.GetResult()); // 콜백 메서드 호출
        }
    }

낯설게 느껴지던 델리게이트를 사용하기보다는 오히려 상속이라는 이미 익숙한 개념으로 콜백을 구현했다.

콜백을 구현할 때 델리게이트와 인터페이스 중에 적당한 선택 기준이 있을까?

대부분의 경우에서 인터페이스가 더 선호된다. 델리게이트는 각 메서드마다 정의해야 하는 불편함이 있지만 인터페이스는 하나의 타입에서 여러 개의 메서드 계약을 담을 수 있기 때문이다. 대신 델리게이트는 여러 개의 메서드를 담을 수 있어서 한 번의 호출을 통해 다중으로 등록된 콜백 메서드를 호출할 수 있다는 고유의 장점이 있다.

 

따라서 다중 호출에 대한 필요성만 없다면 인터페이스를 이용해 콜백을 구현하는 것이 더 일반적이다.

 

Array 타입의 멤버인 Sort메서드는 오름차순으로 정렬하지만 인터페이스 인자를  사용하는 경우

내림차순으로 정렬도 가능하다. Array.Sort에는 아래와 같이 IComparer 인터페이스를 인자로 받는 메서드가 오버로드

되어 제공되기 때문이다.

public static void Sort(Array array);
public static void Sort(Array array, IComparer comparer);

Array와 마찬가지로 IComparer 인터페이스도 닷넷 프레임워크에 정의돼 있고 단순히 Comparer 라는 메서드 유형을 선언하고 있다.

namespace System.Collections
{
    //
    // 요약:
    //     Exposes a method that compares two objects.
    public interface IComparer
    {
        // x가 y보다 크면 1, 같으면 0, 작으면 -1을 반환하는 것으로 약속된 메서드
        int Compare(object? x, object? y);
    }
}

따라서 Array.Sort 정적 메서드를 이용해 내림차순 정렬을 하고 싶다면 약속된 동작을 반대로 수행하는

Comparer 메서드를 준비하면 된다.

class IntegerCompare : IComparer
    {
        
        public int Compare(object x, object y) 
        {
            int xValue = (int)x;
            int yValue = (int)y;

            if (xValue > yValue) return -1;  //
            else if (xValue == yValue) return 0;

            return 1;
        }
        
    }
    
    class Program
    {
        static void Main(string[] args)
        {
            int[] intArray = new int[] { 1, 2, 3, 4, 5 };

            //IComparer를 상속받은 IntegerComparer 인스턴스 전달
            Array.Sort(intArray, new IntegerCompare());
            foreach(int item in intArray)
            {
                Console.Write(item + ", ");
            }
        }
    }

intArray를 전달받은 Array.Sort 정적 메서드는 배열 안의 각 요소를 정렬하기 위해 값을 비교해야 한다. 

만약 IComparer를 구현한 인스턴스를 함께 인자로 넘기면 Array.Sort는 요소의 값을 비교하기 위해 IComparer.Compare 메서드에 2개의 값을 전달한다.

즉, Compare 메서드는 Array.Sort 메서드가 한번 호출될 때 내부에서는 요소의 수에 비례애 여러 번에 걸쳐 호출된다.

 

IEnumerable 인터페이스

인터페이스를 알아본 김에 foreach 문법을 좀 더 알아보자.

IEnumerable은 닷넷 프레임워크 내부에서 제공되며, 다음과 같이 정의돼 있다.

// 닷넷 프레임워크에 정의돼 있는 IEnumerable 인터페이스
public interface IEnumerable
{
    IEnumerator GetEnumerator();	
}

인터페이스에 정의된 유일한 메서드인 GetEnumerator는 열거자라고 하는 객체를 반환하도록 약속돼 있다.

열거자란 IEnumerator 인터페이스를 구현한 객체를 일컫는데, 다시 IEnumerator 인터페이스의 정의를 살펴보면

다음과 같다.

// 닷넷 프레임워크에 정의돼 있는 IEnumerator 인터페이스
public interface IEnumerator
{
	object Current { get; } // 현재 요소를 반환하도록 약속된 get 프로퍼티
    bool MoveNext(); // 다음 순서의 요소로 넘어가도록 약속된 메서드
    void Reset(); // 열거 순서를 처음으로 되돌릴 때 호출하면 되는 메서드
}

IEnumerable 인터페이스를 구현한 전형적인 예는 System.Array다.

C#에서는 IEnumerable 인터페이스를 구현하고 있는 객체에 대해 좀 더 쉽게 열람할 수 있는 열거 문법을 제공한다.

foreach가 바로 그것이다. foreach로 열거되는 문법을 컴파일 시점에 자동으로 바꿔준다.

 

foreach 제어문은 배열과 컬렉션의 요소를 열거하긴 하지만 더 정확하게 말하자면 in 다음에 오는 객체가  IEnumerable 인터페이스를 구현하고 있다면  어떤 객체든 요소를 열거할 수 있다.

string 타입도 IEnumerable 인터페이스를 구현한 사례 중 하나다.

string name = "Korea";
foreach (char ch in name)
{
	Console.Write(ch + ", ");
}

//출력 
K, o, r, e, a,

 

느슨한 결합

인터페이스 사용 사례로 절대 빼놓을 수 없는 중요한 특징 중 하나다.

느슨한 결합을 이해하려면 우선 강력한 결합이 무엇인지 이해할 필요가 있다.

보통 정의하는 클래스 간의 호출이 강력한 결합에 속한다.

class Computer
    {
        public void TurnOn()
        {
            Console.WriteLine("Computer : TurnOn");
        }
    }

    class Switch
    {
        public void PoweOn(Computer machine) // Computer 타입 직접 사용
        {
            machine.TurnOn();
        }
    }

왜 위 코드가 강력한 결합 관계라고 할까?

결합이 강하면 유연성이 떨어진다. 

만약 Switch에 Monitor를 연결한다고 가정해보자. 그러면 이 말이 이해 갈 것이다.

class Monitor
    {
        public void TurnOn()
        {
            Console.WriteLine("Monitor : TurnOn");
        }
    }
    class Switch
    {
        public void PoweOn(Monitor machine) // Computer를 Monitor로 교체
        {
            machine.TurnOn();
        }
    }

Computer에서 Monitor로 바꿨을 뿐인데 코드를 바꾸는 것이 타당한가?

위 코드는 단순하기에 변경 사항이 눈에 띄지만 수천/수만 줄의 코드로 이뤄진 소프트웨어에서 이런 변화가 발생하면 

변경 사항을 추적하기 힘들 것이다.

 

그 보완책이 바로 느슨한 결합이다. 그리고 느슨한 결합을 달성하는 수단이 바로 인터페이스를 사용하는 것이다.

interface IPower
    {
        void TurnOn();
    }

    class Monitor : IPower
    {
        public void TurnOn()
        {
            Console.WriteLine("Monitor : TurnOn");
        }
    }
    class Switch
    {
        public void PoweOn(IPower machine) // Computer나 Monitor등 특정 타입이 아닌 인터페이스로 교체
        {
            machine.TurnOn();
        }
    }

이로써 결합에 대한 문제가 해결됐다. 위 코드를 다시 Computer로 바꾼다해도, 또는 아예 새로운 타입을 정의해 PowerOn메서드를 전달한다 해도 IPower 인터페이스를 상속받는 약속만 지킨다면 내부의 코드는 전혀 변경될 필요가 없다.

 

구조체

값 형식에도 class처럼 사용자 정의 형식을 두기 위해 존재한다.

구조체는 클래스를 정의하는 문법과 유사하다. class 예약어를 struct로 대체한다는 것과 다음의 차이점이 있다.

  1. 인스턴스 생성을 new로 해도 되고 안 해도 된다.
  2. 기본 생성자는 명시적으로 정의할 수 없다. (C# 10부터 구조체에도 기본 생성자를 정의할 수 있다.)
  3. 매개변수를 갖는 생성자를 정의해도 마치 기본 생성자가 있는 것처럼 C#컴파일러에 의해 자동으로 지원된다.
    (클래스의 경우에는 포함되지 않는다.)
  4. 매개변수를 받는 생성자의 경우, 반드시 해당 코드 내에서 구조체의 모든 필드에 값을 할당해야 한다.

따라서 다음과 같이 struct를 정의해서 사용할 수 있다.

struct Vector
    {
        public int X;
        public int Y;

        public Vector(int x, int y) // 배개변수를 가진 생성자 정의
        {
            this.x = x;
            this.y = y;
        }

        public override string ToString()
        {
            return "X: " + x + ", Y: " + y;
        }
    }
    
    class Program
    {
        static void Main(string[] args)
        {
            Vector v1 = new Vector(); // new를 사용해 인스턴스 생성 가능
            Vector v2;                // new 없이도 인스턴스 생성 가능
            Vector v3 = new Vector(5, 10); // 명시적으로 생성자 지정 가능

            Console.WriteLine(v3);
        }
    }

구조체 인스턴스를 new로 생성하는 것은 어떤 의미가 있을까? 값 형식의 변수를 new로 생성하면 해당 변수의 모든 값을 0으로 할당하는 것과 동일한 효과를 갖는다. 따라서 다음의 v1, v2, v3 변수는 같은 의미의 서로 다른 표현일 뿐이다.

Vector v1 = new Vector();

Vector v2;
v2.X = 0;
v2.Y = 0;

Vector v3 = new Vector(0, 0);

이 규칙은 구조체에만 해당하는 것은 아니다. 기본형도 동일하게 new로 할당할 수 있는데, 이 역시 같은 방식으로 해설될 수 있다.

int n1 = new int();

int n2;
n2 = 0;

int n3 = 0;

그러면 값 형식은 다 0으로 초기화되는데 굳이 명시적으로 0을 할당해야 할까? 이는 C#컴파일러 규칙으로 발생하는 차이점 때문이다.

컴파일러는 개발자가 직접 코드 상에서 값을 할당하지 않은 변수를 사용하는 것을 오류라고 판단한다.

 

클래스는 참조형이고 구조체는 값형식이라는 점을 잊어서는 안 된다.

 

깊은 복사와 얕은 복사

값 형식과 참조 형식의 차이점은 인스턴스의 대입이 일어날 때 뚜렷해진다. 다음과 같은 두 가지 사용자 정의 타입이 있고,

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

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

이를 각각 사용하는 경우를 예로 들어보자.

Vector v1; 

v1.X = 5;
v1.Y = 10;

Vector v2 = v1; // 값 형식의 대입

Point p1 = new Point();

p1.X = 6;
p1.Y = 12;

Point p2 = p1; // 참조 형식의 대입

구조체는 인스턴스가 가진 메모리 자체가 복사되어 새로운 변수에 대입되는 것을 볼 수 있는데 이를 깊은 복사라고 한다.

반면, 참조 형식의 변수가 대입되는 방식을 일컬어 얕은 복사라고 한다.

Vector v1;

v1.X = 5; 
v1.Y = 10;

Vector v2 = v1; // 깊은 복사는 인스턴스의 메모리 자체가 복사됨

v2.X = 7;
v2.Y = 14;

Console.WriteLine("v1: X = " + v1.X + ", Y = " + v1.Y);
Console.WriteLine("v2: X = " + v2.X + ", Y = " + v2.Y);

Point pt1 = new Point();

pt1.X = 5;
pt1.Y = 10;

Point pt2 = pt1;

pt2.X = 7;
pt2.Y = 14;

Console.WriteLine("pt1: X = " + pt1.X + ", Y = " + pt1.Y);
Console.WriteLine("pt2: X = " + pt2.X + ", Y = " + pt2.Y);

------------출력 결과--------------

v1: X = 5, Y = 10
v2: X = 7, Y = 14
pt1: X = 7, Y = 14
pt2: X = 7, Y = 14

v1의 값을 v2에 대입한 것은 메모리 상에서 깊은 복사가 일어나고 v2는 새로운 인스턴스를 가리키게 된다.

따라서 v2값을 바꿔도 그 변화가 v1과는 전혀 무관하게 이뤄진다.

하지만 얕은 복사는 이와 다른 결과가 나오는데, 이는 pt1과 pt2가 같은 주소의 인스턴스를 가리키고 있으므로 어느

하나라도 필드의 값을 변경하면 변수의 결괏값이 함께 변경된 것으로 보인다.

 

이런 규칙은 메서드에 인자로 넘길 때도 동일하게 적용된다.

static void Main(string[] args)
{
	Vector v1;
    
    v1.X = 5;
    v2.Y = 10;
    
    Change(v1); //메서드 호출 후 v1값에는 변함이 없음
    Console.WriteLine("v1: X = " + v1.X + ", Y = " + v1.Y);
}

private static void Change(Vector vt)
{
	vt.X = 7;
    vt.Y = 10;
}

---------------출력 결과----------------------

v1: X = 5, Y = 10

값 형식의 v1인스턴스는 메서드로 전달될 때 복제되어 또 다른 인스턴스가 생성되고 해당 인스턴스를 새롭게 vt 변수가 가리킨다. 따라서 Change 메서드 내에서 vt의 변수 값을 변경하는 것은 원래의 v1 변수에 영향을 미치지 않는다.

반면 참조 형식을 메서드에 전달하면

static void Main(string[] args)
{
	Point pt1 = new Point();
    
    pt1.X = 5;
    pt1.Y = 10;
    
    Change(pt1); //메서드 호출 후 pt1의 값이 변함
    Console.WriteLine("pt1: X = " + pt1.X + ", Y = " + pt1.Y);
}

private static void Change(Point pt)
{
	pt.X = 7;
    pt.Y = 10;
}

//
---------------출력 결과----------------------

pt1: X = 7, pt1 = 14

pt1과 pt변수는 동일한 인스턴스를 가리킨다. 즉, 메서드에 넘겨진 느 것은 변수가 가진 참조 주소일 뿐이다.

이 때문에 메서드에서 값을 변경하면 그 영향이 메서드를 호출한 측의 참조 변수에도 미친다.

 

깊은 복사의 장점은 값의 변경에 대한 간섭을 일으키지 않음으로써 개발자가 당연히 원했던 동작을 한다는 것이다.

이 말은 곧 참조 주소만을 전달함으로써 때때로 원치 않는 값의 변경이 발생하는 얕은 복사의 단점을 설명해 준다.

하지만 때로는 그런 장점이 단점이 되기도 한다.

예를 들어 구조체 가 내부에 많은 필드를 담게 되어 크기가 1024바이트 까지 커졌다고 가정하자.

해당 구조체 변수를 메서드에 전달할 때마다 컴퓨터는 1KB의 메모리 영역을 매번 복사하는 작업을 해야 한다.

반면 그와 같은 내용을 클래스로 정의했다면 메서드를 호출할 때마다 참조 주솟값만 복사하면 되므로 구조체와 비교해

월등한 성능 향상을 가져온다.

 

이쯤에서 구조체와 클래스를 선택하는 기준을 알아보자

  1. 일반적으로 모든 사용자 정의 타입은 클래스로 구현한다.
  2. 깊은/얕은 복사의 차이가 민감한 타입은 선택적으로 구조체로 구현한다.
  3. 참조 형식은 나중에 배울 GC에 의해 관리받게 된다. 따라서 참조 형식을 사용하는 경우
    GC에 부담이 되는데, 이런 부하를 피해야 하는 경우에는 구조체를 선택한다.

ref 예약어 (C# 7.0에서는 ref예약어를 지역 변수와 반환값에도 적용할 수 있다)

깊은 복사와 얕은 복사는 동작 방식에 공통점이 있는데, 다름 아닌 변수의 스택 값은 여전히 복사된다는 점이다. 값 형식의 변수는 해당 변수가 실제 값을 가리키고 있고, 따라서 그 값이 복사되어 전달된다. 반면 참조 형식의 변수는 힙에 존재하는 실제 데이터의 주솟값을 가리키고 있으며 따라서 그 주솟값이 복사되어 전달된다.

이렇게 변수의 스택 값이 복사되는 상황을 특별히 메서드의 인자 전달과 관련해 값에 의한 호출(CBV : Call By Value)이라고 한다.

 

이와 구분해서 참조에 의한 호출 ( CBR: Call By Reference)이라는 방법도 있다. 이 방식으로 메서드에 인자를 전달하면

변수의 스택값이 복사되는 것이 아니라 해당 변수의 스택 값을 담고 있는 주소 자체가 전달된다.

C#에서는 참조에 의한 호출을 지원하기 위해 두 가지 예약어를 추가했는데 바로 ref와 out이다. (C# 7.2에서 in 예약어가 추가된다)

 

참조에 의한 호출이 어떤 의미를 갖는지 ref 예약어를 사용한 예제로 알아보자.

ref는 두 군데에서 사용해야 되는데

  1. 메서드의 매개변수를 선언할 때 함께 표기해야 하고
  2. 해당 메서드를 호출하는 측에서도 명시해야 한다.

다음은 값 형식의 구조체에 ref예약어를 사용한 예제인데 출력 결과를 자세히 보자

class Program
    {
        static void Main(string[] args)
        {
            Vector v1;

            v1.X = 5;
            v1.Y = 10;

            Change(ref v1);
            Console.WriteLine("v1: X = " + v1.X + ", Y = " + v1.Y);
        }

        static void Change(ref Vector vt)
        {
            vt.X = 7;
            vt.Y = 14;
        }
    }
    
    ------------출력 결과--------------
    v1: X = 7, Y = 14

얼핏 보면 ref예약어는 구조체를 클래스처럼 얕은 복사로 전달한 것과 동일한 효과를 낸다.

하지만 얕은 복사와 ref 예약어는 분명하게 동작 방식에 차이가 있다.

ref를 사용하지 않았을 때는 전형적인 값 형식의 스택 복사가 있었지만, 이를 사용하게 되면 메서드의 vt 변수가

호출 측의 v1 변수와 동일한 주소를 가리키게 된다. 다음은 이해를 돕기 위해 ref를 사용하는 예를 도식화한 것이다.

기존의 얕은 복사와 깊은 복사는 변수의 스택 값이 복사되어 전달됐지만 위의 그림에서는 v1 변수가 가리키고 있는 데이터의 주솟값(0x1600)이 vt에도 그대로 전달되어 결국 같은 메모리의 주소를 가리키는 것을 볼 수 있다.

 

ref예약어는 참조형 변수에도 사용할 수 있다. 그런데 값 형식에 대해 얕은 복사 효과를 내는 ref 예약어가 참조형 변수에 대해서는 어떤 동작을 할까?

참조형 변수가 ref 예약어 때문에 동작 방식이 달라진다. 아래 예제를 보면 실행 결과는 원래의 참조형 변수가 얕은 복사로 전달된 것과 동일하다.

class Program
    {
        static void Main(string[] args)
        {
            Point pt1 =new Point();

            pt1.X = 5;
            pt1.Y = 10;

            Change(ref pt1); // 메서드 호출: ref 예약어 사용
            Console.WriteLine("pt1: X = " + pt1.X + ", Y = " + pt1.Y);
        }

        static void Change(ref Point pt)
        {
            pt.X = 7;
            pt.Y = 14;
        }
    }
    
    
    
    ----------출력 결과------------
    pt1: X = 7, Y = 14

참조형 변수를 ref 예약어로 전달한 효과를 구분하려면 좀 더 특수한 예제가 필요하다.

class Program
    {
        static void Main(string[] args)
        {
            Point pt1 = null;
            
            Change1(pt1); // 메서드 호출: 얕은 복사
            Console.WriteLine("pt1: " + pt1);
            
            Change2(ref pt1); // 메서드 호출: ref 사용            
            Console.WriteLine("pt1: X = " + pt1.X + ", Y = " + pt1.Y);
        }

        private static void Change1(Point pt)
        {
            pt = new Point();
            pt.X = 6;
            pt.Y = 12;
        }
        
        private static void Change2(ref Point pt)
        {
            pt = new Point();
            pt.X = 7;
            pt.Y = 14;
        }
    }
    
    
    
    ----------출력 결과------------
    pt1:
    pt1: X = 7, Y = 14

Change1 메서드와 Change2 메서드의 호출 결과가 왜 다를까?

Change1 메서드를 호출하면 참조 값이 또 다른 메모리에 복사되어 전달 되므로 메서드 내에서의 new 메모리 할당이

원래의 pt1 변수에 영향을 미치지 않는다.

 

하지만 ref 예약어와 함께 전달되는 경우에는 결과가 달라진다. pt1 변수의 스택 주솟값이 직접 전달 되므로 pt1과 pt 

변수는 같은 곳을 가리키게 되고 Change2의 메서드 내에서의 new  할당이 그대로 원본 pt1 변수에도 반영된다.

https://www.sysnet.pe.kr/0/0

 

J & J - 정성태의 닷넷 이야기: Digital Stories

 

www.sysnet.pe.kr

https://ridibooks.com/books/1160000100

 

시작하세요! C# 10 프로그래밍

시작하세요! C# 10 프로그래밍 작품소개: 이 책의 목표는 여러분이 C#을 이용해 프로그래밍 기초를 탄탄하게 다질 수 있게 하는 것이다. 이를 위해 C# 언어의 최신 버전인 C# 10의 문법까지 구체적인

ridibooks.com