C#은 마이크로소프트에서 만든 컴퓨터 언어로, C#을 이해하기 위한 메뉴얼 혹은 설명서 같은 것이 C# MSDN이다. 개발자를 위한 개발 가이드나 개념이 정리되어 있으니 한 번쯤 읽어보도록 하자.
이전 내용에서 상속에 관해 정리한 바가 있을 것이다. 상속이란 것은 부모클래스와 자식클래스와의 관계로, 부모의 특성을 자식이 그대로 가지고 오는 것임을 기억할 것이다.
그렇다면, 이 클래스에도 제일 위에 있는, 최상위의 부모클래스가 있을까?
Object 타입
모든 클래스의 가장 위에 있는 최상위 부모클래스
Object는 클래스 중에 제일 높은 최상위 클래스이다. 이 오브젝트에 관해서 C# MSDN의 설명을 참조하면 이렇게 나온다.
참조 형식과 값 형식을 비롯한 모든 형식을 상속한다고 되어 있다. 여기에서 System.Object(Object 클래스)에 대한 정보도 한 번 확인해 보자.
모든 닷넷 클래스의 궁극적인 기본 클래스, 즉 부모클래스라 할 수 있다. 따라서 이와 같은 것이 가능하다.
문자열, 정수, 문자, 실수, 심지어 구조체나 클래스까지 전부 담을 수 있는 것을 확인할 수 있다.
이렇게 어떤 자료형의 형태든지 간에 담을 수 있는 것이 상당히 좋아 보인다. 하지만, Object는 생각보다 사용하기 불편한 면모가 있다.
Object로 선언된 변수는 말 그대로 Object 변수가 되는 것이기 때문에, int형 변수에서 가능했던 연산이 불가능하다. 다른 변수 또한 각각 가능했던 기능 등이 제한된다.
왜 이런 현상이 발생할까? 이걸 이전에 배웠던 상속에서의 부모클래스와 자식클래스의 특성을 생각해 보면 이유를 알 수 있다.
부모클래스로 선언된 변수는 자식클래스의 함수를 사용할 수 없다. 따라서 다운 캐스팅을 통해서 자식클래스로 변환한 뒤에 자식클래스 함수를 사용할 수 있다.
Object 또한 클래스, 그 중에서도 가장 최고위의 클래스인 만큼, 업 캐스팅과 다운 캐스팅의 과정이 있다는 걸을 추측할 수 있다. 실제로, Object의 업 캐스팅과 다운 캐스팅을 아래와 같은 용어로 정의한다.
- Boxing(박싱) : 값형식 -> 참조형식
업 캐스팅과 동일한 원리이며, 암시적으로 사용 가능하다- Unboxing(언박싱) : 참조 형식 -> 값형식
다운캐스팅과 동일한 원리이며, 명시적으로 해야 한다. (변환 자료형의 확인이 필요)
코드를 예시로 들면 아래와 같이 박싱을 하고, 언박싱을 할 수 있다.
int value = 10;
// 업캐스팅 : 암시적으로 사용가능 (박싱 : 값형식 -> 참조형식)
object obj = value;
// 다운캐스팅 : 명시적으로 해야함 (언박싱 : 참조 형식 -> 값형식)
int result = int obj;
Object 자료형은 언뜻 보면 자료형이 정해지지 않기 때문에 편해 보이는 기능이다. 다만 object 타입은 박싱과 언박싱 과정에서의 속도가 현저히 느리기 때문에, 어쩔 수 없는 상황에서만 사용하되 되도록 사용은 권장하지 않는다.
( 지양하는 방식 )
var : 암시적 타입 지역변수(implicitly typed local variable)
Var이라는 자료형이 있다. 이 자료형의 경우 '자료형이 정해지지 않은 상태'의 변수를 생성할 때 사용한다. 선언과 동시에 값 초기화가 되어야지 사용할 수 있는 특성이 있다.
위와 같이 Object처럼 어떤 변수를 넣어도 오류가 발생하지 않는 것을 볼 수 있다. 언뜻 보기에는 Object와 유사한 것으로 보이나, 실제로는 조금 다른 양상을 띈다.
Var은 Object와 달리 업 캐스팅을 하는 것이 아니라, 컴파일러가 암시적으로 자료형을 결정하게 하는 것이기 때문에 Var로 선언된 변수는 그 고유의 연산이 가능하다.
Var도 상황에 따라서는 적절히 활용할 수 있으나, 남발하는 것은 오히려 변수의 자료형 판단을 어렵게 하고 가독성을 떨어트릴 수 있으니 사용하는 걸 권장하지 않는다.
앞서 Object 형식에 대해 배워보았다.
Object는 변환 과정에서 야기하는 성능 저하 문제나, 형변환 중의 위험성 등으로 사용을 피해야 한다고 말한 바 있다.
이런 Object의 문제점을 보완한 것이 제네릭이다.
일반화(Generic)
-클래스 또는 함수가 코드에 의해 선언되어 인스턴스화될 때까지, 형식의 사양을 연기하는 디자인
-기능을 구현한 뒤 자료형을 사용 당시에 지정해서 사용
제네릭은 박싱/언박싱 단계를 거치지 않고 형식을 조정하여 성능이 향상되고, 컴파일 단계에서 올바른 데이터 형식이 적용되므로 형식 안정성이 보장된다. 또한 Object 형식을 사용할 때보다 간결하고, 직관적인 코드 작성에 도움을 준다.
그러면 제네릭을 사용하려면 어떻게 사용해야 할까. 아래와 같이 코드를 작성해 보자
internal class Program
{
static void Main(string[] args)
{
int a = 10;
int b = 20;
Util.Swap(ref a, ref b);
Console.WriteLine("a = {0}, b = {1}", a, b);
}
}
public class Util
{
public static void Swap(ref int left, ref int right)
{
int temp = left;
left = right;
right = temp;
}
}
두 숫자의 위치를 바꾸기 위한 함수, Swap을 만들어 보았다. 하지만 이 함수에서는 int형의 자료형만 사용할 수 있다는 한계가 있다. 함수에 다른 자료형을 넣기 위해서 제네릭을 사용할 것이다.
일반화 함수
일반화는 자료형의 형식을 지정하지 않고 함수를 정의
기능을 구현한 뒤 사용 당시에 자료형의 형식을 지정해서 사용
함수에서 제네릭을 사용하는 방법은, 함수명 뒤에 <(자료형으로 선언할 문자)> 를 넣어주면 된다.
문자는 한 글자든, 문자열이든, 대소문자든 상관없이 사용할 수 있지만, 보툥 현업에서 많이 쓰이는 글자는 T
라고 하니 그리 쓰도록 하겠다.
그러면 위의 코드를 조금 수정하여 제네릭을 어떻게 선언하는지 보고, 결과 또한 확인해 보겠다.
internal class Program
{
static void Main(string[] args)
{
int a = 10;
int b = 20;
Util.Swap(ref a, ref b);
Console.WriteLine("a = {0}, b = {1}", a, b);
float c = 2.4f;
float d = 1.5f;
Util.Swap(ref c, ref d);
Console.WriteLine("c = {0}, d = {1}", c, d);
}
}
public class Util
{
public static void Swap<T>(ref T left, ref T right)
{
T temp = left;
left = right;
right = temp;
}
}
결과가 잘 출력된 것을 알 수 있다.
가령, 이런 경우가 있을 수 있다.
위의 함수에서 제네릭으로 선언한 것으로, 어떠한 자료형이든 위치를 바꿀 수가 있게 되었다.
하지만 의도와는 다른 자료형이 변환된 것을 확인할 수 있었다.
위의 Swap 코드로 문자열 또한 Swap이 가능하며, 이것이 원하지 않는 의도라고 생각하여 해당 기능을 제약하고자 한다. 이것을 위한 방법 또한 있다.
일반화 자료형 제약
일반화 자료형을 선언할 때 제약조건을 선언하여, 사용 당시 쓸 수 있는 자료형을 제한
자료형 제약을 선언하는 방법은 다음과 같다.
함수의 맨 뒤에 Where <(문자)> : (사용할 자료형/구조체/클래스 등) 을 선언해주면 된다.
그러면 위의 코드를 수정하여 어떻게 달라지는 확인해보자.
Swap의 자료형 제약을 struct로 해 주니, class 형인 string은 사용하지 못하게 된 것을 확인할 수 있다.
이런 생각도 들 수 있다. 지금 하는 이 방식, 상속에서의 함수 오버로딩과 유사한 기능 아닌가?
함수 오버로딩으로 해도 될 것 같은데 굳이 필요한 기능일까 의문이 들 수도 있다.
결론부터 말하면 둘의 기능이 비슷한 것은 맞지만, 약간의 차이점이 있다.
함수 오버로딩의 경우 해당 조건을 프로그래머가 모두 작성해 주어야 한다는 점이다. 반면에 일반화를 사용하면 일반화만의 장점이 있는 것이, 프로그래머가 작성하지 않은 조건에 대해서도 자동으로 사용할 수 있게 된다.
다만 예상치 못한 이용 방법이 없는지 확인하도록 한다.
C# 언어에서는 다중상속을 지원하지 않는다. 그 이유는 다중 상속으로 인한 모호성, 즉 에러의 발생 위험성 때문이다.
상속의 모호성을 보여주는 대표적인 예시, 죽음의 다이아몬드의 자료 예시다.
최초의 부모클래스인 A에서 B, C 각각으로 인자가 전달되었고, 이를 D에서 상속하려고 하면 B의 것을 상속해야 하는가, C의 것을 상속해야 하는가? 이를 컴퓨터가 판단내릴 수 없기 때문에 오류가 발생한다.
이와 같은 상황을 방지하기 위해서 다중상속을 막는다고는 하지만, 다중상속을 사용하면 좋은 상황 또한 있을 것이다. C#에서는 이러한 다중 상속을 사용할 수 있는 방법으로, 인터페이스를 사용할 수 있다.
인터페이스
-인터페이스란 일종의 약속을 만들고, 인터페이스를 적용한 클래스에 대해 이 약속을 강제시키는 것
-인터페이스는 내부에 구현을 강제할 메서드를 가지고, 이를 적용받은 클래스에서 구현하지 않으면 이를 상기시킨다. 이를 반대로 표현하자면 인터페이스를 포함하는 클래스는 반드시 인터페이스의 구성 요소들을 구현했다는 것을 보장한다는 의미이다.
-인터페이스는 메서드와 자동구현 프로퍼티만을 가질 수 있고, 이외의 필드는 가질 수 없다
-Can-a 관계 : 클래스가 해당 행동을 할 수 있는 경우 적합함
상당히 많은 내용이 담겨 있는데, 이를 요약하자면 아래와 같이 말할 수 있다.
- 인터페이스를 만들고, 안에 구현해야 할 메서드를 작성한다. (그 외의 필드는 넣을 수 없다)
- 해당 인터페이스를 다른 클래스에 적용하고, 해당 클래스는 인터페이스의 메서드를 구현해야 한다.
- A는 B를 할 수 있다 와 같이 성립하는 관계에서 인터페이스가 적합하다.
이와 같은 점을 유념하여 인터페이스를 사용해 보자.
예를 들어 아래와 같은 상황을 생각해 보자
다중 상속이 유리한 어떤 상황에 대해 생각해 보자.
1. 문과 상자는 열 수 있다.
2. 문과 던전은 입장할 수 있다.
문은 열 수 있다는 특성과 입장할 수 있다는 특성 두 가지 다 가지고 있으므로, 이를 인터페이스로 구현하고자 한다.
문의 기능을 구현하기 위해 두 가지 인터페이스, 연다와 입장한다의 개념을 만들어 보자.
인터페이스를 선언하기 위해서 public interface (이름)으로 선언한다.
public interface IEnterable
{
public void Enter();
}
public interface IOpenable
{
public void Open();
}
인터페이스의 이름은 일반적으로 대문자 I 로 시작하고, ~이 가능한 이라는 형태로 이름을 만든다.
이와 같은 방식으로 클래스까지 작성해보자.
아래와 같이 던전이란 클래스를 만들고, 뒤에 " : (인터페이스 이름)" 을 적어 인터페이스를 적용시켰다. 이런 인터페이스를 적용하니 오류가 떴다. IEnterable 인터페이스의 멤버를 구성하지 않았다는 알림이다.
코드를 마저 작성하여 인터페이스가 어떻게 적용되는지 알아보자.
public interface IEnterable
{
public void Enter();
}
public interface IOpenable
{
public void Open();
}
public class Dungeon : IEnterable
{
public void Enter()
{
Console.WriteLine("던전에 들어갑니다.");
}
}
public class Chest : IOpenable
{
public void Open()
{
Console.WriteLine("상자를 엽니다.");
}
}
public class Door : IEnterable, IOpenable
{
public void Enter()
{
Console.WriteLine("문에 들어갑니다.");
}
public void Open()
{
Console.WriteLine("문을 엽니다.");
}
}
public class Player
{
public void Enter(IEnterable enterable)
{
enterable.Enter();
}
public void Open(IOpenable openable)
{
openable.Open();
}
}
이와 같이 작성하고서 메인에 출력을 해 보자.
static void Main(string[] args)
{
Player player = new Player();
Dungeon dungeon = new Dungeon();
Chest chest = new Chest();
Door door = new Door();
player.Enter(dungeon);
player.Open(chest);
player.Open(door);
player.Enter(door);
}
이와 같이 작성했을 때 출력은 아래와 같이 나온다.
이와 같이 인터페이스의 기능에 대해 배워 보았을 때, 문득 이런 의문이 들 수 있다.
인터페이스의 기능이 이전에 배웠던 추상클래스와 상당히 유사한 면모가 있다는 점이다.
다만 이 둘은 명백한 차이점이 있으며 사용 방법 또한 상이하다.
- 추상클래스
-공통점 : 함수에 대한 선언만 정의하고 이를 포함하는 클래스에서 구체화하여 사용
-차이점 : 변수, 함수의 구현 포함 가능 / 다중 상속 불가
-사용처 : (A Is B 관계)
상속 관계인 경우, 자식 클래스가 부모클래스의 하위 분류인 경우
상속을 통해 얻을 수 있는 효과를 얻을 수 있음
부모클래스의 기능을 통해 자식클래스의 기능을 확장하는 경우 사용
- 인터페이스
-공통점 : 함수에 대한 선언만 정의하고 이를 포함하는 클래스에서 구체화하여 사용
-차이점 : 변수, 함수의 구현 포함 불가 / 다중포함 가능
-사용처 : (A Can B 관계)
행동 포함인 경우, 클래스가 해당 행동을 할 수 있는 경우
인터페이스를 사용하는 모든 클래스와 상호작용이 가능한 효과를 얻을 수 있음
인터페이스에 정의된 함수들을 클래스의 목적에 맞게 기능을 구현하는 경우 사용
1) 인터페이스를 이용한 제약
인터페이스로 특정 기능을 구현하도록 만들 수도 있지만, 특정 기능 외의 기능을 실수로 넣었을 때 만들 수 없도록 제약을 거는 용도로도 사용할 수 있다.
ex) 데미지를 입는다는 인터페이스를 만들고, 몬스터가 플레이어에게 데미지를 입도록 설정시켰는데, 실수로 몬스터가 데미지를 입게 만들면서 동시에 플레이어가 점프하게 만들어버리면? 이 점프 기능 함수가 오류로 된다.
2) 다형성을 고려한 코드 제작
흔히 온라인 게임에서 플레이어가 똑같은 키를 눌러도 상대가 NPC냐, 상자냐, 문이냐에 따라서 상호작용이 달라지는 경우를 봤을 것이다. 해당 작용을 인터페이스로 구현할 수 있으며, 다형성을 고려한 코드를 작성해 보았다.
interface IInteractable
{
public void Interaction();
}
class Door : IInteractable
{
public void Interaction()
{
Open();
}
public void Open()
{
Console.WriteLine("문을 엽니다");
}
}
class NPC : IInteractable
{
public void Interaction()
{
Talk();
}
public void Talk()
{
Console.WriteLine("NPC와 대화를 합니다");
}
}
class Chest : IInteractable
{
public void Interaction()
{
Open();
}
public void Open()
{
Console.WriteLine("상자를 엽니다");
}
}
class Player
{
public void Action(IInteractable interactable)
{
interactable.Interaction();
}
}
internal class Program
{
static void Main(string[] args)
{
Player player = new Player();
Door door = new Door();
NPC nPC = new NPC();
Chest chest = new Chest();
player.Action(door);
player.Action(nPC);
player.Action(chest);
}
}
이에 대한 출력은 아래와 같이 나온다.
확장메서드
-클래스의 원래 형식을 수정하지 않고도 기존 형식에 함수를 추가하는 것
-상속을 통하여 만들지 않고도 추가적인 함수를 구현할 수 있다.
-정적 함수에 첫 번째 매개변수를 this 키워드 후 확장하고자 하는 자료형을 작성한다.
예를 들어, 어떤 문자열의 단어 개수를 만드는 함수를 아주 자주 사용하여, 해당 기능을 고정적으로 사용하고 싶다고 하자.
문자열과 관련하여 콘솔에서 기본적으로 제공하는 기능이 아주 많지만, 단어의 개수를 세는 기능은 없다. 이를 확장 메서드로 만들어 보도록 하자.
public static class Extention
{
public static int WordCount(this string text)
{
string[] words = text.Split(' ');
return words.Length;
}
}
이와 같이 확장메서드를 만든 후 메인에서 사용해보자.
메인에서 string과 관련된 기본제공 기능처럼 WordCount가 생성된 것을 볼 수 있다. 이제 해당 기능을 사용하여 정상 작동하는지 확인해보자.
text에 적은 단어의 개수는 4개이니 정상적으로 출력된 것을 확인할 수 있다.
위와 같이 확장메서드가 유용한 기능인 점은 알았다. 다만, 이를 남발하는 것은 다소 유의해야 할 부분이다.
확장 메서드를 만들기 위해서는 해당 클래스와 함수 모두 static으로 선언하여 작성하여야 한다. 하지만 앞서 static에 대해 배웠다시피, static 함수는 프로그램의 시작과 종료시까지 유지되는 데이터이다 보니, 확장 메서드를 너무 많이 만들면 메모리의 손실이 있을 것이다.
이와 같은 점을 유념하여 필요한 기능 이상의 확장 메서드를 만드는 것은 자제하도록 하자.
참고자료
(C# MSDN)
https://learn.microsoft.com/ko-kr/dotnet/csharp/language-reference/builtin-types/reference-types
(죽음의 다이아몬드)
https://blog.naver.com/wjddudgns514/221258390409