현실 세계를 보면 인간과 침팬지는 영장류에 속하고, 영장류와 고래, 기린 등은 포유류에 속한다. 이런 식으로 어떤 공통적인 특징이 있고 그 특징을 상속(inheritance)받아 다른 세부적인 항목을 정의하는데, 일상적인 많은 객체가 이런 '계층적'인 관계를 따른다.
상속이라는 개념이 없을 때 노트북, 데스크톱, 넷북 클래스는 다음과 같이 개별적으로 메서드와 상태 값을 정의해야 한다.
public class Notebook
{
bool powerOn;
public void Boot(){}
public void shutdown(){}
public void Reset(){}
bool finerScan; //public 특화 멤버 필드 추가
public bool HasFingerScanDevice() //Notebook 특화 멤버 메서드 추가
{
return fingerScan;
}
public class Desktop
{
bool powerOn;
public void Boot(){}
public void Shutdown(){}
public void Reset(){}
}
public class Netbook
{
bool powerOn;
public void Boot(){}
public void Shutdown(){}
public void Reset(){}
}
이 처럼 개별적으로 메서드와 상태 값을 정의해야 하는 문제를 상속을 이용하면 공통적인 특징을 정의하는 부모 클래스(parent class)를 두고 자식 클래스(cjild class)에서 부모의 기능을 물려받는 식으로 처리할 수 있다.
- 부모 클래스는 다른 말로 기반(base) 클래스 또는 슈퍼(super) 클래스라고도 한다. 그리고 자식 클래스는 다른 일로 파생(derived) 클래스 또는 서브(sub) 클래스라고도 한다. 아울러 부모 또한 부모를 가질 수 있기 때문에 조상(ancestor) 클래스라는 표현이 있으며, 반대로 자손(desendant) 클래스라는 표현도 있다.
//상속을 이용한 클래스 정의
public class Computer
{
bool powerOn;
public void Boot(){}
public void Shutdown(){}
public void Reset(){}
}
public class Notebook : Computer
{
bool fingerScan; //Notebook 타입에 해당하는 멤버만 추가
public bool HasFingerScanDevice(){return fingerScan;}
}
public class Desktop : Computer
{
}
public class Netbook : Computer
{
}
C#에서는 클론(:)을 이용해 부모 클래스의 기능을 물려받을 수 있고 실제로 상속받은 클래스는 부모의 속성과 행위를 접근 제한자 규칙에 따라 외부에 제공한다.
public class Notebook : Computer
{
bool fingerScan;
public bool HasFingerScanDevice(){return fingerScan;}
public void CloseLid()
{
Shutdown(); //Notebook에서 추가된 메서드 내에서 부모의 메서드 호출
}
}
public Program
{
static void Main(string[] args)
{
Notebook noteBook = new Notebook();
noteBook.Boot(); //Notebook 인스턴스에 대해 부모의 메서드 호출
}
}
private 접근 제한자가 적용된 멤버는 오직 그것을 소유한 클래스에서만 접근할 수 있다.
따라서 자식 클래스일지라도 부모의 private 멤버에 접근하는 것은 허용되지 않는다.
public class Notebook : Computer
{
bool fingerScan;
public bool HasFingerScanDevice(){return fingerScan;}
public void CloseLid()
{
if(powerOn == true) //컴파일 오류 발생 : 접근 불가(inaccessible)
{
Shutdown();
}
}
}
class의 멤버를 private처럼 외부에서의 접근은 차단하면서도 자식에게는 허용하고 싶다면?
protected 접근 제한자를 사용한면 된다.
public class Computer
{
protected bool powerOn;
public void Boot(){}
public void Shutdown(){}
public void Reset(){}
}
public class Notebook : Computer
{
//생략
}
프로그래밍을 하다보면 상속을 의도적으로 막고 싶을 때도 있다. 일례로 string 타입은 상속을 더는 받지 못하도록 제한되어 있는데, 이는 sealed 예약어가 적용되어 있기 때문이다.
sealed class Pen
{
}
public class Electricpen : Pen //컴파일 오류 발생
{
}
C#의 상속은 단일 상속(single inheritance)만 지원한다.
아래의 코드는 C#에서는 작성할 수 없다.
class Computer{}
class Monitor{}
class Notebook : Computer, Monitor //컴파일 오류 발생
{
}
C#은 '계층 상속'은 가능하지만 동시에 둘 이상의 부모 클래스로부터 다중 상속*multiple inheritance)을 받는 것은 허용하지 않는다.
C# 언어를 설명할 때 '배보다 배꼽이 더 크다'는 표현을 쓰기 적절한 곳이 바로 '상속'이다.
상속 자체의 개념은 부모의 기능을 물려받는 것으로 매우 간단하게 설명되지만, 그로인해 파생되는 여러가지 개념이 복잡하게 얽혀있다.
형 변환
형 변환 중 가장 일반화된 타입으로 '정수(자연수 및 그것의 음수와 0)가 있고, 그 중에서 -2,147,483,648 ~ 2,147,483,647 범위의 수는 int 타입에 속한다. 그 중에서 -32,768 ~ 32,767 범위에 속하는 수는 short 타입이다.
이 관계를 정리하면 다음과 같다.
정수는 int 영역을 포함하고 int는 short 영역을 포함한다.
달리 말하면, 정수→int → short 순으로 '일반화 → 특수화'하는 모습을 보이고 있다.
형 변환에 적용해 정리하면 다음과 같다.
- 암시적 형 변환
특수화 타입의 변수에서 일반화된 타입의 변수로 값이 대입되는 경우
short a = 100;
int b = a; //암시적 형변환
- 명시적 형변환
일반화 타입의 변수에서 특수화된 타입의 변수로 값이 대입되는 경우
int c = 100;
short d = (short)c; //명시적 형변환
이 규칙은 class로 정의된 타입의 부모/자식 관계에도 동일하게 적용된다.
예를 들어 위에서 작성했던 Computer는 가장 일반적인 개념으로 그 범위는 COmputer를 상속받은 Notebook을 포함한다.
Notebook은 Computer의 특수화 타입인 것이다.
따라서 Notebook(특수화 타입) 인스턴스를 Computer(일반화 타입)의 변수로 대입하는 경우에는 암시적 형 변환이 가능하다.
Notebook noteBook = new Notebook();
Computer pc1 = new noteBook; //암시적 형 변환, 컴파일은 가능
pc1.Boot();
pc1.Shutdown();
반대로 부모 클래스(일반화 타입)의 인스턴스를 자식 클래스(특수화 타입의 변수로 대입하는 것은 암시적 변환이 불가능하다.
물론 강제로 캐스팅 연산자를 사용해 명시적 형 변환을 하는 것은 가능하지만, 실행하면 오류가 발생한다.
//부모 인스턴스를 자식으로 형 변환하는 경우
Computer pc = new Computer();
Notebook notebook = (Notebook)PC; //명시적 형 변환, 컴파일은 가능
//실행하면 오류 발생
왜 오류가 발생한는가?
Notebook에는 Computer가 정의하지 않은 3개의 멤버(fingerScan, HasFingerScanDevice, CloseLid)가 추가되어 있지만, new Computer(); 코드에서 할당한 메모리에는 Notebook을 위한 멤버의 특성을 반영하고 있지 않다. 그런 상태에서는 당연히 HasFingerScanDevice 메서드를 호출하면 프로그램 실행이 엉망이 될 수 있기 때문에 실행 단계에서 오류를 발생시키는 것이다.
컴파일 단계에서부터 명시적 형 변환을 불가능하게 만들었다면?
개발자가 의도적으로 원하는 경우도 있기 때문에 컴파일 단계에서부터 명시적 형 변환을 불가능하게 만들 수 없다.
ex) 자식 인스턴스를 가리키는 부모 클래스의 변수가 다시 자식 타입의 변수로 대입될 수 있다.
Notebook noteBook = new Notebook();
Computer pc1 = noteBook; //부모 타입으로 암시적 형 변환
Notebook note2 = (Notebook)pc1; //다시 본래 타입으로 명시적 형 변환
note2.CloseLid();
현실적으로 볼때 클래스 간의 명시적 형 변환보다는 암시적 형 변환이 좀 더 자주 사용된다.
//생략
public class DeviceManager
{
public void TurnOff(Computer device)
{
device.Shutdown();
}
}
class Program
{
static void Main(string[] args)
{
Notebook notebook = new Notebook();
Desktop desktop = new Desktop();
Netbook netbook = new Netbook();
DeviceManager manager = new DeviceManager();
manager.TurnOff(notebook);
manager.TurnOff(desktop);
manager.TurnOff(netbook);
}
}
또는 각 자식 클래스의 인스턴스를 부모 객체의 배열에 담을 수 있는 것도 암시적 형 변환 덕분이다.
//배열 요소에서의 암시적 형 변환
Computer[] machines =
new Computer[] { new Notebook(), new Desktop(), new Netbook() }; //암시적 형 변환
DiviceManager manager = new DeviceManager();
foreach(Computer device in machines)
{
manager.TurnOff(device);
}
as, is 연산자
클래스의 형 변환에서 빠질 수 없는 것이 as 연산자이다.
캐스팅 연산자를 사용해 명시적 형 변환을 하는 경우 컴파일 단계가 아닌 프로그램을 실행할 때 오류가 발생한다는 것을 알 수 있다.
.NET 프로그램에서 오류를 발생시키는 것은 내부적으로 제법 부하가 큰 동작에 속한다. 따라서 오류를 발생시키지 않고도 형 변환이 가능한지 확인할 수 있는 방법이 필요했고 이를 위해 as 연산자가 추가되었다.
Computer pc = new Computer();
Notebook notebook = pc as Notebook;
if(notebook != null) //코드대로라면 if문 내부의 코드가 실행될 가능성은 없다.
{
notebook.CloseLid();
}
as는 형 변환이 가능하면 지정된 타입의 인스턴스 값을 반환하고, 가능하지 않으면 null을 반환하기 때문에 null 반환 여부를 통해 형 변환이 성공했는지 판단할 수 있다.
기억해야 할 점
as 연산자는 참조형 변수에 대해서만 적용할 수 있고 참조형 타입으로의 체크만 가능하다.
- 이 규칙 때문에 다음 코드는 모두 컴파일 할 때 오류가 발생한다.
//as의 잘못된 사용
intn = 5;
if((n as string) != null) //컴파일 오류 발생
{
Console.WriteLine("변수 n은 string 타입");
}
string txt = "text";
if((txt as int) != null) //컴파일 오류 발생
{
Console.WriteLine("변수 txt는 int타입");
}
as가 형변환 결과값을 반환하는 반면, is 연산자는 형 변환의 가능성 유무를 boolean 형의 결과값으로 반환한다.
as와 is 연산자를 언제 사용하느냐에 대한 기준은 명확하다.
형 변환된 인스턴스가 필요하다면 as를 사용하고, 필요없다면 is를 사용하면 된다.
int n = 5l
if(n is string)
{
Console.WriteLine("변수 n은 string 타입");
}
string txt = "text";
if(txt is int)
{
Console.WriteLine("변수 txt는 int 타입");
}
is 연산자가 as 연산자와는 다른 또 하나의 특징
대상이 참조형식 뿐 아니라 값 형식에서 사용할 수 있다. 그래서 위의 코드를 컴파일하면 오류가 발생하지 않는다.
모든 타입의 조상: System.Object
클래스를 정의할 때 부모 클래스를 명시하지 않는다면 C# 컴파일러는 기본적으로 object라는 타입에서 상속받는다고 가정하고 자동으로 코드를 생성한다.
public class DeviceManager
{
}
//또는,
public class DeviceManager : object
{
}
부모 클래스를 지정하더라도 그 부모 클래스는 어떤 클래스를 다시 상속받았을 것이고 결국에는 최초의 클래스가 object 타입을 상속 받는 것으로 끝난다.
결국 C#에서 정의되는 모든 클래스의 부모는 object가 된다.
object는 모든 클래스의 부모이므로 다음과 같은 코드 작성이 가능하다.
Computer computer = new Computer();
Object obj1 = computer;
Computer pc1 = obj1 as Computer;
Notebook notebook = new Notebook();
object obj2 = notebook;
Notebook pc2 = obj as Notebook;
object는 그 자체가 참조형이지만, 값 형식의 부모 타입이기도 하다.
참조 형식과 값 형식은 처리 방식이 매우 다른데, 이러한 불일치를 구분하기 위해 닷넷에서는 모든 값 형식을 System.ValueType 타입에서 상속받게 하고 있으며, 다시 System.ValueType은 object를 상속받는다.
즉, 값 형식은 System.ValueType으로부터 상속받은 모든 타입을 의미하고, 참조 형식은 object로부터 상속받은 타입 가운데 System.ValueType의 하위 타입을 제외한 모든 타입을 의미한다.
C#에서 정의되는 모든 형식은 object로 변환하고 다시 되돌리는 것이 가능하다.
이런 특성이 objecct를 다소 특별하게 생각하도록 만들 수 있지만, .NET에서 object는 단순히 하나의 클래스에 지나지 않는다.
.NET 내부에 이미 정의되어 있다는 차이만 있을 뿐 여느 클래스와 같은 방식으로 정의되어 있고 다음과 같은 4개의 public 메서드를 포함하고 있다.
namespace System;
public class Object
{
public virtual bool Equals(object obj);
public virtual int GetHashCode();
public Type GetType();
public virtual string ToString();
}
object는 C#에서 정의된 예약어이고, 실체는 System 네임스페이스에 정의된 Object 라는 클래스로 존재한다.
C# | 대응되는 닷넷 프레임워크 형식 | 특징 |
object | System.Object | 모든 C# 클래스의 부모 |
기본타입 -object
모든 클래스는 object를 상속받기 때문에 당연히 object가 가진 메서드를 제공한다.
object의 대표적인 4가지 메서드
Tostring
ToString 메서드를 호출하면 해당 인스턴스가 속한 클래스의 전체 이름(FQDN)을 반환한다.
ex) Program 클래스에 대해 ToString을 호출한 결과
ToString 메서드는 자식 클래스에서 기능을 재정의할 수 있는데, string을 비롯해서 C#에서 제공되는 기본 타입(short, int, ...)은 모두 ToString을 클래스의 전체 이름이 아닌 해당 타입이 담고 있는 값을 반환하도록 변경했다.
ToString과 관련된 몇가지 예제
GetType
C#에서는 개발자가 class 타입을 정의하면 내부적으로 해당 class 타입의 정보를 가지고 있는 System.Type의 인스턴스를 보유하게 되고, 바로 그 인스턴스를 가져올 수 있는 방법이 GetType 메서드를 통해 제공된다.
Computer computer = new Computer();
Type type = computer.GetType();
Console.WriteLine(type.FullName); //Type 클래스의 FullName 프로퍼티 출력
Console.WriteLine(type.IsClass); //Type 클래스의 IsClass 프로퍼티 출력
Console.WriteLine(type.isArray); //Type 클래스의 IsArray 프로퍼티 출력
ToString 메서드가 휘 클래스에서 재정의되면 타입의 전체 이름이 아닌 값 자체를 문자열로 반환한다.
GetType 메서드는 그러한 클래스에 대해 타입의 전체 이름을 반환하는 수단을 제공한다.
GetType은 '클래스의 인스턴스'로부터 Type을 구하는 반면, '클래스의 이름'에서 곧바로 Type을 구하는 방법도 제공한다.
이 때는 typeof라는 예약어를 사용하면 된다.
Equals
Equals 메서드는 값을 비교한 결과를 불린형으로 반환한다.
int n = 5;
Console.WriteLine(n.Equals(5)); //출력결과: True
문제는 비교 대상이 '값 형식'과 '참조 형식'에 대해 달라진다는 점이다. 값 형식에 대해서는 해당 인스턴스가 소유하고 있는 값을 대상으로 비교하지만, 참조 형식에 대해서는 할당된 메모리 위치를 가리키는 식별자의 값이 같은지 비교한다.
-이를 달리 표현하면 object는 할당된 메모리 위치를 가리키는 식별자의 값이 같은지를 비교하는 Equals 메서드를 제공하지만, System.ValueType의 하위 클래스는 그와 같은 기본 동작 방식을 재정의했다고 표현할 수 있다.
그 차이는 아래의 코드를 확인
int n1 = 5;
int n2 = 5;
Console.WriteLine(n1.Equals(n2)); //출력결과: True
n2 = 6;
Console.WriteLine(n1.Equals(n2)); //출력결과: False
값 형식 하나인 int 타입은 변수가 가리키는 값 자체를 대상으로 결과값을 반환한다.
class로 생성한 참조 형식은 어떻게 되는가?
//참조 형식의 Equals 메서드의 동작 방식
class Book
{
decimal _isbn;
public Book(decimal isbn)
{
_isbn = isbn;
}
}
Book book1 = new Book(9788998139018);
Book book2 = new Book(9788998139018);
Console.WriteLine(book1.Equals(book2)); //출력 결과: False
동일한 값을 소유한 참조 형식에 대해서 equals 메서드는 False를 반환한다.
'힙에 할당된 데이터 주소를 가리키고 있는 스택 변수의 값'을 비교하기 때문이다.
즉, new Book으로 생성한 힙 메모리의 위치가 다르기 때문에 그 안에 들어간 값이 어떤 것이든 상관 없이 Equals 메서드는 False를 반환하게 된다.
참조 형식에 대한 Equals 메서드의 이 같은 동작 방식이 과연 실용성 있는가?
이 때문에 object는 하위 클래스에서 Equals에 대한 동작 방식을 재정의할 수 있도록 허용한다.
한가지 좋은 예시 - string 참조 타입
string Equals의 기본동작을 재정의하지 않았다면 출력 결과는 False로 나왔을 것이다. 하지만 string은 불변 클래스이며, 값 비교를 위해 Equals가 오버라이딩 되어 있습니다. 따라서 string 타입에서는 Equals가 값 비교로 동작한다.
GetHashCode
GetHashCode 메서드는 특정 인스턴스를 고유하게 식별할 수 있는 4바이트 int 값을 반환한다.
기억해둬야 할 한가지
GetHashCode가 Equals 메서드와 연계되는 특성이 있다는 점이다.
Equals의 반환값이 True인 객체라면 서로 같음을 의미하고, 그렇다면 그 객체들을 식별하는 고유값 또한 같아야 한다.
반면 Equals 반환 값이 False라면 GetHashCode의 반환값도 달라야 한다.
이 때문에 보통 Equals 메서드를 하위 클래스에서 재정의하면 GetHashCode까지 재정의하는데, 이를 따르지 않으면 컴파일 경고가 발생한다.
object에 정의된 GetHashCode는 참조 타입에 대해 기본 동작을 정의해뒀는데, 생성된 참조형 타입의 인스턴스가 살아있는 동안 .NET Runtime 내부에서 그러한 인스턴스에 부여한 식별자 값을 반환하기 때문에 적어도 프로그램이 실행되는 중에 같은 타입의 다른 인스턴스와 GetHashCode 반환값이 겹칠 가능성은 많지 않다.
반면 값 타입에 대해서는 GetHashCode의 동작 방식을 재정의해서 해당 인스턴스가 동일한 값을 가지고 있다면 같은 해시코드를 반환한다.
- 출력 결과는 어떤 버전의 닷넷 런타임에서 실행했느냐에 따라 달라질 수 있다. GetHashCode의 반환 값은 .NET Runtime의 내부 구현이 변경되면 언제든지 바뀔 수 있다.
GetHashCode의 반환값이 4바이트 int값이라는 점에 주의해야한다.
즉, 값의 범위가 -2147483648 ~ 2147483647로 제한된다. 만약 short 타입을 만드는 개발자라면, short는 값의 범위가 -32768
~ 32767이라서 그냥 그 값 자체는 반환해도 다른 short 인스턴스와 겹치지 않을 수 있다.
int 타입의 경우 공교롭게도 GetHashCode의 반환값과 정확히 일치하므로 그대로 GetHashCode의 반환값과 1:1 매핑할 수 있다.
실제로 .NET 개발자들은 int 이하의 타입에 대한 GetHashCode를 재정의해 그 값을 그대로 반환하게 해놓았다.
객체를 대표하는 값의 경우의 수가 2^32을 넘으면 안된다는 점은 상황에 따라 충족하지 못할 수도 있다.
ex) 8바이트 long일 경우?
long 타입은 값의 범위가 2^64이므로 32비트 정수 범위 내에서 표현하는 것이 불가능하다. 따라서 long 값에 대해 GetHashCode를 호출하면 어떤 경우에는 값이 다른데도 동일한 해시 코드가 반환될 수 있다.
- 서로 다른 값인데도 동일한 해시 코드를 생성하는 것을 두고 해시 충돌(hash collision)이 발생했다고 표현한다.
이런 특성 때문에 GetHashCode는 Equals와 다시 연결된다. 해당 객체를 고유하게 식별하는 값이 2개 이상 나올 확률이 있으므로 해시 코드의 값이 같다면 다시 한번 Equals를 호출해서 정말 객체가 동일한지 판단할 수 있는 기회가 생긴다.
기억해둬야할 것 : GetHashCode는 경우에 따라 해시 충돌이 발생할 수 있다는 점이다.
모든 배열의 조상:System.Array
object가 모든 타입의 조상인 것처럼 소스코드에 정의되는 배열은 모두 Array 타입을 조상으로 둔다.
ex)
int[] intArray = new int[] { 0, 1, 2, 3, 4, 5};
이 경우 C# 컴파일러는 자동으로 int[] 타입을 Array 타입으로부터 상속받는 것으로 처리한다.
이로 인해 배열 인스턴스는 Array 타입이 가진 모든 특징을 제공하는데, 일부 속성 및 메서드는 알아두면 유용하다.
멤버 | 타입 | 설명 |
Rank | 인스턴스 프로퍼티 | 배열 인스턴스의 차원(dimension) 수를 반환한다. |
Length | 인스턴스 프로퍼티 | 배열 인스턴스의 요소(element) 수를 반환한다. |
Sort | 정적 메서드 | 배열 요소를 값의 순서대로 정렬한다. |
GetValue | 인스턴스 메서드 | 지정된 인덱스의 배열 요소 값을 반환한다. |
Copy | 정적 메서드 | 배열의 내용을 다른 배열에 복사한다. |
Array 타입의 멤버
namespace ConsoleApp2;
class Program
{
private static void OutputArrayInfo(Array arr)
{
Console.WriteLine("배열의 차원 수: " + arr.Rank); //Rank 프로퍼티
Console.WriteLine("배열의 차원 수: " + arr.Length); //Length 프로퍼티
Console.WriteLine(); //Rank 프로퍼티
}
private static void OutputArrayElements(string title, Array arr)
{
Console.WriteLine("[" + title + "]");
for(int i = 0; i< arr.Length; i++)
{
Console.Write(arr.GetValue(i) + ", "); //GetValue 인스턴스 메서드
}
Console.WriteLine();
Console.WriteLine();
}
static void Main(string[] args)
{
bool[,] boolArray = new bool[,] { { true, false }, { false, false } };
OutputArrayInfo(boolArray);
int[] intArray = new int[] { 5, 4, 3, 2, 1, 0 };
OutputArrayInfo(intArray);
OutputArrayElements("원본 intArray", intArray);
Array.Sort(intArray); //Sort 정적 메서드
OutputArrayElements("Array.Sort 후 intArray", intArray);
int[] copyArray = new int[intArray.Length];
Array.Copy(intArray, copyArray, intArray.Length); //Copy 정적 메서드
OutputArrayElements("intArray로부터 복사된 copyarray", copyArray);
}
}
중요한 것은 배열이 System.Array로부터 상속받은 참조형 타입이다.
this
객체는 외부에서 자신을 식별할 수 있는 변수를 갖는다.
Book book = new Book(9788998139018);
변수 book은 마침표(.)를 사용해 객체의 멤버를 호출할 수 있는데, 클래스 내부의 코드에서 객체 자신을 가리킬 수 있는 방법은?
this 예약어이다.
class Book
{
decimal _isbn;
public decimal ISBN
{
get{ return this._isbn; }
}
public Book(decimal isbn)
{
this._isbn = isbn;
}
public decimal GetISBN()
{
return this.ISBN;
}
public void Sell()
{
Console.WriteLine("Sell: " + this.GetISBN());
}
}
this 표현을 쓰고 안쓰고는 개발자의 취향이다. 어떤 개발자는 메서드 내에서 멤버 변수에 접근할 때 그것이 멤버 변수임을 명확히 인식할 수 있게 this를 명시하기도 한다.
선택의 문제를 넘어서 꼭 필요한 경우
다음과 같이 메서드의 매개변수와 클래스에 정의된 필드의 이름이 같을 경우 this를 명시함으로써 멤버 변수 isbn을 사용하게 만들 수 있다.
class Book
{
decimal isbn;
public Book(decimal isbn)
{
this.isbn = isbn; //this를 생략하면 메서드의 매개변수인 isbn 변수가 사용된다.
}
}
생성자에서도 this를 사용하는 경우가 있다. 다중 생성자를 사용한 예제를 보면 초기화 관련 코드가 중복되어 사용된 것을 볼 수 있는데, 이는 '중복 코드 제거' 원칙에 위배된다.
이런 경우 this 예약어를 사용해 생성자 내에서 다른 생성자를 호출하게 만들 수 있다.
class Book
{
string title;
decimal isbn13;
string author;
public Book(string title) : this(title, 0)
{
}
public Book(string title, decimal isbn13) : this(title, isbn13, string Empty)
{
}
//초기화 코드를 하나의 생성자에서 처리
public Book(string title, decimal isbn13, string author)
{
this.title = title;
this.isbn13 = isbn13;
this.author = author;
}
public Book() : this(string.Empty, 0, string Empty)
{
}
}
this를 사용해 또 다른 생성자를 호출하는 구문을 사용함으로써 초기화 관련 코드를 하나의 메서드 내에서 처리하게 했다.
이런 식으로 this를 사용할 수밖에 없는 전형적인 사례들이 있다.
이를 제외하고 단순히 클래스의 멤버에 접근하기 위해 this를 명시하는 것은 선택의 문제이다.
this와 인스턴스/정적 멤버의 관계
인스턴스 멤버와 정적 멤버의 차이를 this 예약어를 사용할 수 있느냐 없느냐로 나눌 수 있다.
this는 new로 할당된 객체를 가리키는 내부 식별자이므로 클래스 수준에서 정의되는 정적 멤버는 this 예약어를 사용할 수 없다.
class Book
{
string title; //인스턴스 필드
static int count; //정적 필드
public Book(string title) //인스턴스 생성자
{
this.title = title; //this로 인스턴스 필드 식별 가능
this.Open(); //this로 인스턴스 메서드 식별 가능
Increment(); //정적 메서드 사용 가능
}
void Open() //인스턴스 메서드
{
Console.WriteLine(this.title); //인스턴스 멤버 사용 가능
Console.WriteLine(count); //정적 멤버 사용 가능
}
public void Close()
{
Console.WriteLine(this.title + "책을 덮는다.");
}
static void Increment() //정적 메서드
{
count++; //정적 필드 사용 가능
//정적 메서드에는 this가 없으므로 인스턴스 멤버 사용 불가능
}
}
클래스에 정의되는 메서드를 인스턴스로 할 것이냐 정적으로 할 것이냐에 대한 기준이 하나 더 추가될 수 있다.
해당 메서드의 내부에서 this 예약어를 사용해야 하고, 인스턴스 멤버에 접근해야 한다면 정적 메서드로 정의해서는 안된다.
반면, this 예약어를 사용하지 않는다면 인스턴스 메서드로 만들거나 정적 메서드로 만들어 사용하는 것이 가능하다.
인스턴스 메서드를 기술적인 관점에서 바라보면 this의 마법을 이해할 수 있다. C# 컴파일러는 메서드 호출 시 this를 인스턴스 메서드의 첫 번째 인자로 넘겨주는 식으로 구현한다.
예시)
Book book = new Book("");
book.Close();
C# 컴파일러는 위의 코드를 빌드할 때 자동으로 다음과 같이 변환한다.
Book book = new book("");
book.Close(book);
메서드에 해당 객체를 가리키는 인스턴스 변수를 인자로 넘기는 것이다. 그와 동시에 C# 컴파일러는 인스턴스 메서드도 다음과 같이 변환해서 컴파일한다.
class Book
{
string title; //인스턴스 필드
//생략
public void Close(Book this)
{
Console.WriteLine(this.title + "책을 덮는다.");
}
}
this 식별자의 존재는 이처럼 컴파일러의 노력을 빚어낸 결과이다. 이 때문에 모든 인스턴스 메서드는 인자를 무조건 1개 이상 더 받게 되어 있으므로 내부에서 인스턴스 멤버에 접근할 일이 없다면 정적 메서드로 명시하는 것이 성능상 유리할 수 있다.
-최근의 기가바이트(GB)급, 기가헤르츠(GHz)급 CPU에서 메서드가 인자를 하나 더 받는다고 성능상 크게 문제되는 경우는 많지 않다. 그래도 여전히 비주얼 스튜디오의 '코드 분석기(Code analysis)' 같은 도구는 this가 필요 없는 메서드를 정적으로 정의하지 않은 경우 성능 경고를 발생시킨다.
base
this 예약어가 클래스 인스턴스 자체를 가리키는 것과 달리 base 예약어는 '부모 클래스'를 명시적으로 가리키는데 사용된다.
this와 마찬가지로 부모 클래스의 멤버를 사용할 때 base 키워드가 생략된 것이나 다름없다.
public class Computer
{
bool powerOn;
public void Boot{}
public vodi Shutdown(){}
public void Reset(){}
}
public class Notebook : Computer
{
bool fingerScan;
public bool HasFingerScanDevice(){return fingerScan;}
public void CloseLid()
{
base.Shutdown(); //base 예약어를 명시
}
}
this와 마찬가지 이유로 base 예약어를 명시하고 안하고는 선택의 문제이다.
생성자에서 사용되는 패턴도 this와 유사하다.
ex)1개의 매개변수를 생성자에서 받게 되어 있는 클래스로부터 상속 받는 자식 클래스를 정의하면 다음과 같다.
//상속받는 경우 생성자로 인한 오류
class Book
{
decimal isbn13;
public Book(decimal isbn13)
{
this.isbn13 = isbn13;
}
}
class EBook : Book
{
public EBook() //에러 발생
{
}
}
이 코드를 컴파일하면 public EBook 생성자 정의에서 오류가 발생하는 것을 볼 수 있다.
자식 클래스를 생성한다는 것은 곧 부모 클래스의 생성자도 함께 호출한다는 의미이기 때문이다.
부모 클래스를 만드는 개발자는 private으로 소유하고 있는 멤버를 초기화할 수 있지만, 자식 클래스는 부모 클래스의 private 멤버에 접근할 수 없으므로 초기화가 불가능하다. 또한 부모 클래스의 초기화는 그 클래스를 만든 개발자가 가장 잘 알고 있기 때문에 자식 클래스에서 부모 클래스의 초기화까지 담당하는 것은 무리가 있다.
- 생성자는 그것이 정의된 클래스 내부의 필들를 초기화하는 일만 담당하면 되고, 부모 클래스의 필드는 부모 클래스의 생성자가 초기화할 것이므로 맡기면 된다.
- 위 코드에서 오류가 발생하는 이유는 자식 클래스가 생성되는 시점에 부모 클래스의 생성자를 호출해야 하는데, '기본 생성자'가 부모 클래스에는 없기 때문이다.
- 부모 클래스에서 제공되는 Book(decimal isbn13) 생성자를 C# 컴파일러가 자동으로 연계해 줄 수 없다.
- isbn13값을 어떤 값을 넣어야 할지 컴파일러 입장에서 알 수 없으며, 부모 클래스의 생성자가 여러 개 있는 상황에서는 어떤 생성자를 자동으로 호출해야 할지도 모호하다. 이런 경우에 base 예약어를 이용해 어떤 생성자를 어떤 값으로 호출해야 할지 명시해서 문제를 해결할 수 있다.
class Book
{
decimal isbn13;
public Book(decimal isbn13)
{
this.isbn13 = isbn13;
}
}
class EBook : Book
{
public EBook() : base(0)
{
}
public EBook(decimal isbn) : base(isbn) //또는 이렇게 값을 연계하는 것도 가능하다.
{
}
}
Reference
시작하세요! C# 12 프로그래밍 기본 문법부터 실전 예제까지
'C#' 카테고리의 다른 글
C# 객체지향 문법 [C#의 클래스 확장 ~ 델리게이트] (0) | 2025.05.19 |
---|---|
C# 객체지향 문법 [다형성] (0) | 2025.05.17 |
C# 객체지향 문법 [캡슐화] (0) | 2025.05.13 |
C# 객체지향 문법 [생성자, 종료자, 정적멤버, 인스턴스 멤버, 네임스페이스] (0) | 2025.05.13 |
C# 객체지향 문법 [클래스, 필드, 메서드] (0) | 2025.05.12 |