C#은 객체지향 프로그래밍(Object Oriented Programming) 언어이고 객체지향 프로그래밍은 프로그램의 처리 대상이 되는 모든 것을 객체(Object)로서 표현합니다. 예컨대 '자동차'라는 대상을 객체로 표현한다면 '달리다.', '멈추다.'등과 같은 대상의 동작을 메서드(Method)로, '색상', '속도', '배기량'같은 대상의 특징을 속성(Property)으로 추출해 표현하면 그것이 객체가 됩니다.
클래스(Class)라는 건 이 객체를 생성하기 위한 틀을 말하는 것인데 객체를 표현하기 위해 어떤 속성과 메서드가 필요한지를 판단하고 그것을 '클래스'로 정의합니다. 객체를 만들어 내기 위한 설계도 같은 것으로 클래스로부터 객체(객체를 인스턴스(Instance)라고도 합니다.)를 생성하고 그 객체를 통해서 필요한 속성에 값을 부여하거나 메서드로 동작을 실행하는 형태로 프로그래밍이 이루어집니다.
1. 클래스
클래스는 class키워드를 사용해 구현되며 중괄호({})안에 클래스의 속성과 메서드가 정의됩니다.
class Car
{
}
예제에서 Car는 클래스의 이름이며 구현하고자 하는 실체에 따라 적절히 이름을 지정해 주고 '추상화'를 통해서 실체에서 필요한 메서드와 속성을 구현하면 됩니다.
class Car
{
public int Speed; //속도 필드
public int Displacement; //배기량 필드
public string Color; //색상 필드
//주행 메서드
public void Drive()
{
WriteLine("자동차 주행");
}
//정지 메서드
public void Stop()
{
WriteLine("자동차 정지");
}
}
예제에서 사용된 Speed나 Color와 같은 클래스 안에 선언된 변수나 속성을 '필드'라고 합니다. 또한 Drive()와 Stop()와 같은 것은 '메서드'라고 하는데 이 밖에도 이벤트나 속성등 클래스안에서 선언될 수 있는 것들은 여러가지가 있으며 클래스안에 구현된 모든 요소들을 통틀어 '클래스의 멤버'라고 합니다.
클래스를 통해 필요한 요소를 추출해 구현했으면 이를 통해 다음과 같은 방법으로 객체를 생성합니다.
class MyTestApp
{
static void Main(string[] args)
{
Car sedan = new Car();
sedan.Speed = 0;
sedan.Displacement = 2000;
sedan.Color = "black";
sedan.Drive();
}
}
class Car
{
public int Speed; //속도 필드
public int Displacement; //배기량 필드
public string Color; //색상 필드
//주행 메서드
public void Drive()
{
WriteLine("자동차 주행");
}
//정지 메서드
public void Stop()
{
WriteLine("자동차 정지");
}
}
객체는 new키워드를 통해 생성되며 뒤 이어 Car()라는 생성자 메서드를 호출했습니다. 이 메서드는 특별히 구현하지 않아도 자동으로 생성되는 메서드로서 클래스의 객체를 생성해 주는 메서드라 할 수 있습니다.
참고로 클래스의 객체를 생성하는 경우 C# 9.0이후라면 다음과 같이 new() 만으로 객체생성이 가능합니다.
Car sedan = new();
참고로 클래스는 복합 데이터 형식으로 참조 형식입니다. 객체는 클래스에서 갖는 데이터를 직접적으로 갖고 있지 않고 생성자가 힙 메모리에 생성한 메모리의 위치만을 가리킬 뿐입니다.
2. 생성자
위에서 언급했듯이 클래스에서 객체를 생성하기 위해서는 생성자라는 메서드를 호출해야 합니다. 생성자는 클래스의 객체를 생성해 주는 특별한 역할을 수행해 주는 메서드로 클래스와 이름이 같고 특별한 반환 형식을 갖지 않는다는 특징이 있습니다.
생성자는 일부러 구현하지 않아도 컴파일러가 알아서 기본 생성자를 만들어 주지만 필요하다면 다음과 같이 임의로 구현하는 것도 가능합니다. 다만 생성자를 임의로 구현하는 경우 컴파일러는 기본 생상자를 만들어 주지 않습니다.
class Car
{
public Car()
{
Speed = 100;
}
public int Speed; //속도 필드
public int Displacement; //배기량 필드
public string Color; //색상 필드
//주행 메서드
public void Drive()
{
WriteLine("자동차 주행");
}
//정지 메서드
public void Stop()
{
WriteLine("자동차 정지");
}
public void State()
{
WriteLine($"현재속도 : {Speed}");
}
}
예제에서는 생성자를 임의로 만들어 생성자가 호출될 경우 Speed의 값을 100으로 초기화하도록 하였습니다.
class MyTestApp
{
static void Main(string[] args)
{
Car sedan = new Car();
sedan.Displacement = 2000;
sedan.Color = "black";
sedan.State(); //현재속도 : 100
}
}
생성자도 메서드이기에 일반 메서드처럼 여러 가지 오버 로드된 버전을 필요한 만큼 만들 수 있습니다.
class MyTestApp
{
static void Main(string[] args)
{
Car sedan = new Car(80, "Red");
sedan.Displacement = 2000;
sedan.State(); //현재속도 : 80 / 자동차색상 : Red
}
}
class Car
{
public Car()
{
Speed = 100;
}
public Car(int SetSpeed, string SetColor)
{
Speed = SetSpeed;
Color = SetColor;
}
public int Speed; //속도 필드
public int Displacement; //배기량 필드
public string Color; //색상 필드
//주행 메서드
public void Drive()
{
WriteLine("자동차 주행");
}
//정지 메서드
public void Stop()
{
WriteLine("자동차 정지");
}
public void State()
{
WriteLine($"현재속도 : {Speed} / 자동차색상 : {Color}");
}
}
3. 종료자
생성자와 반대되는 개념의 종료자는 객체가 메모리에서 소멸할 때 실행되는 메서드입니다.
class Car
{
public Car()
{
Speed = 100;
}
public Car(int SetSpeed, string SetColor)
{
Speed = SetSpeed;
Color = SetColor;
}
public int Speed; //속도 필드
public int Displacement; //배기량 필드
public string Color; //색상 필드
//주행 메서드
public void Drive()
{
WriteLine("자동차 주행");
}
//정지 메서드
public void Stop()
{
WriteLine("자동차 정지");
}
public void State()
{
WriteLine($"현재속도 : {Speed} / 자동차색상 : {Color}");
}
~Car()
{
}
}
소멸자 메서드는 ~문자를 사용해 간단히 구현할 수 있지만 여러 가지 이유로 잘 사용하지 않습니다. 우선 소멸자는 임의로 호출될 수 없고 CLR의 가비지 컬렉터가 해당 객체를 소멸하는 시점에 호출해 줍니다. 문제는 가비지컬렉터가 언제 객체를 소멸할지 알 수 없으므로 소멸자가 실행되는 시점 또한 예측할 수 없기 때문에 실무에서도 쓰는 일이 거의 없습니다.
4. 정적(static) 멤버
클래스 안에서 필드나 메서드를 정의할 때 static 한정자를 통해서 해당 필드나 메서드를 정적으로 선언할 수 있습니다.
class Cal
{
public static int param1;
public static int param2;
public static int Plus()
{
return param1 + param2;
}
}
정적 멤버와 메서드는 인스턴스가 아닌 클래스 자체에서 직접 접근하거나 호출할 수 있습니다.
class MyTestApp
{
static void Main(string[] args)
{
Cal.param1 = 10;
Cal.param2 = 20;
WriteLine(Cal.Plus());
}
}
프로그램에 전역에 걸쳐서 공유되는 값을 정적 필드로 선언하거나 외부 값의 참조없이 내부에서만 처리되는 형태로 프로그램 전체에 걸쳐 사용되는 경우 메서드를 정적메서드로 선언하여 사용합니다. 하나의 클래스에서 인스턴스는 여러개 생성될 수 있지만 클래스자체는 단 하나만 존재할 수 있으므로 프로그램 전역에서 공유되는 성격이 강한것만을 추려서 정적필드로 선언해 활용하기도 합니다.
또 하나 염두에 둬야 할 건 인스턴스의 경우 인스턴스가 생성될 때 필요한 메모리를 확보하고 참조하지만 정적 멤버는 프로그램이 실행될 때 메모리에 미리 올라가게 된다는 특징이 있습니다. 이러한 특징으로 인해 프로그램의 시작 성능에 영향을 줄 수 있으므로 너무 많은 값을 정적으로 사용하지 않도록 해주는 것이 좋습니다.
5. 얕은 복사와 깊은 복사
클래스는 참조 형식이므로 클래스를 복사하면 실제값이 아닌 힙에 할당된 메모리 주소를 가리키는 스택의 값만 복사가 이루어집니다.
class MyTestApp
{
static void Main(string[] args)
{
TestClass tc = new TestClass();
tc.param1 = 10;
tc.param2 = 20;
TestClass tc2 = tc;
tc2.param1 = 20;
WriteLine($"{tc.param1} - {tc2.param1}"); //똑같이 20을 출력
}
}
class TestClass
{
public int param1;
public int param2;
}
이러한 복사를 '얕은 복사'라고 합니다. 서로 다른 인스턴스는 하나의 값을 참조하고 있으므로
한쪽 인스턴스에서만 값을 바꿔도 같은 값을 참조하고 있던 다른 인스턴스도 변경된 값을 바라보게 되는 것입니다.
하지만 얕은 복사가 아닌 다른 힙으로 분리되어 저장되는 깊은 복사가 필요하다면 클래스에서 직접 인스턴스를 생성해 값을 할당해야 합니다.
class MyTestApp
{
static void Main(string[] args)
{
TestClass tc = new TestClass();
tc.param1 = 10;
tc.param2 = 20;
TestClass tc2 = new TestClass();
tc2.param1 = 20;
tc2.param2 = 30;
WriteLine($"{tc.param1} - {tc2.param1}"); //10과 20을 표시
}
}
깊은 복사가 필요한 경우라면 직접 만들 클래스에서 ICloneable인터페이스를 상속받아 Clone() 메서드를 구현할 수도 있습니다. 즉, 해당 클래스가 깊은 복사 기능의 메서드가 구현되어 있음을 보장하는 것입니다. 물론 .NET의 여러 기본 클래스에도 같은 방법으로 Clone() 메서드가 구현되어 있는데 그중 하나가 배열입니다.
class MyTestApp
{
static void Main(string[] args)
{
int[] i = new int[] {1, 2, 3};
int[] j = i;
j[1] = 4;
WriteLine($"{i[1]} - {j[1]}"); //4 - 4
}
}
배열도 참조라서 단순 복사를 수행하게 되면 얕은 복사가 이루어집니다. 그래서 한쪽에서 값을 바꾸면 다른 쪽에도 바뀐 값을 바라보게 됩니다.
class MyTestApp
{
static void Main(string[] args)
{
int[] i = new int[] {1, 2, 3};
int[] j = (int[])i.Clone();
j[1] = 4;
WriteLine($"{i[1]} - {j[1]}"); //2 - 4
}
}
하지만 위에서 말씀드린 Clone() 메서드를 사용하면 깊은 복사가 이루어져 값이 분리됩니다. 이러한 방법으로 .NET에서 여러 클래스를 다루는 도중 깊은 복사가 필요한 경우가 발생한다면 Clone() 메서드가 구현되어 있는지 우선 확인하고 있다면 해당 메서드를 사용하여 편리하게 깊은 복사를 수행하면 됩니다.
6. this
● 키워드로서의 this
객체에서 자신의 멤버에 접근할 때 자기 자신을 가리키는 데 사용되는 키워드입니다.
class TestClass
{
public int i;
public int j;
public int Plus(int i, int j)
{
this.i = i;
this.j = j;
return this.i + this.j;
}
}
TestClass의 Plus() 메서드에서는 i, j 두 개의 매개변수를 가지고 있는데 공교롭게도 같은 이름의 필드가 클래스 내부에 존재합니다. 이름이 같은 경우 자신의 i인지 외부의 i인지 혼란스러울 수 있지만 이때 자신의 내부 필드인 경우 this. 키워드를 사용하면 이런 혼란을 방지할 수 있습니다.
● 생성자로서의 this
필요에 따라 클래스의 생성자를 여러 개 사용하는 경우 코드의 중복을 최소화하기 위한 장치입니다.
class TestClass
{
public int x, y, z;
public TestClass(int x)
{
this.x = x;
}
public TestClass(int x, int y)
{
this.x = x;
this.y = y;
}
public TestClass(int x, int y, int z)
{
this.x = x;
this.y = y;
this.z = z;
}
}
TestClass는 3개의 생성자를 사용하고 있는데 각 생성자마다 각각의 필드를 초기화하고 있습니다. 문제는 생성자마다 this.x = x; 라는 코드가 중복으로 구현되어 있는데 필드수가 많다고 가정하면 더 많은 중복되는 코드를 남발해야 합니다.
바로 이런 경우 this() 생성자를 통해 이전의 생성자를 호출하여 중복을 최소화할 수 있습니다.
class TestClass
{
public int x, y, z;
public TestClass(int x)
{
this.x = x;
}
public TestClass(int x, int y) : this(x)
{
this.y = y;
}
public TestClass(int x, int y, int z) : this(x, y)
{
this.z = z;
}
}
두 번째 생성자에서는 매개변수를 2개를 받지만 this()의 사용으로 x는 첫번째 생성자로 대체할 수 있게 되었습니다. 세번째 생성자 역시 x, y 두개의 매개변수를 두번째 생성자로 대체하여 같은 필드의 반복적인 초기화를 피할 수 있게 되었습니다.
7. 한정자
객체지향 프로그래밍에서의 은닉성(Encapsulation), 상속 성(Inheritance), 다형성(Polymorphism) 특성 중 은닉성을 구현하기 위한 것으로 객체 내부를 최대한 감추고 필요한 것만 외부에 노출하도록 하는 것을 말합니다.
객체에서 은닉성을 사용하는 이유는 객체를 사용하는 입장에서 잘못된 접근을 최대한 차단하고자 하는 데 있습니다. 예를 들어 아래와 같은 Car클래스에서 속도 필드인 speed를 선언한다고 했을 때
class MyTestApp
{
static void Main(string[] args)
{
Car sedan = new Car();
sedan.speed = 200;
}
}
class Car
{
//속도는 0~100입니다.
public int speed;
}
내부적으로 이 값을 0 이하/100 이상으로는 설정할 수 없도록 하고 싶지만 클래스를 통해 생성된 객체에서는 임의로 speed 필드로 접근하여 원하는 값을 부여하고 있습니다.
이런 경우 한정자를 통해 은닉성을 구현하여 발생할 수 있는 문제를 사전에 차단할 수 있습니다.
class MyTestApp
{
static void Main(string[] args)
{
Car sedan = new Car();
sedan.Speed(50);
sedan.Speed(101); //메세지 출력
}
}
class Car
{
private int speed;
public void Speed(int s)
{
if (s < 0 || s > 100) {
WriteLine("속도는 0부터 100까지만 가능합니다.");
}
else {
speed = s;
}
}
}
본래 사용되던 speed 필드는 외부에서 접근할 수 없도록 하고 대신 외부에 노출되는 메서드를 추가하여 speed변수에 값을 부여할 수 있도록 하였습니다. 메서드에서는 입력되는 값을 확인하여 허용된 범위를 벗어나는 경우 메시지를 출력하고 변수에 값을 할당하지 않습니다.
한정자는 speed 필드나 Speed() 메서드에서 처럼 private이나 public으로 사용된 것을 말하는데 외부에서의 접근 수준을 지정하게 되며 C#에서는 다음과 같은 한정자를 사용할 수 있습니다.
한정자 | 용도 |
public | 클래스 외부에서 자유롭게 접근할 수 있습니다. |
protected | 해당 클래스에서 파생된 자식 클래스에서만 접근할 수 있습니다. |
private | 클래스 내부에서만 접근할 수 있습니다. |
internal | 클래스가 속한 같은코드(어셈블리 단위) 안에서만 접근할 수 있습니다. |
protected internal | 클래스가 속한 같은코드(어셈블리 단위) 안에서 protected로 접근할 수 있습니다. |
private protected | 클래스가 속한 같은코드(어셈블리 단위) 안에서 해당 클래스를 상속한 코드에서만 접근할 수 있습니다. |
만약 위의 한정자를 별도로 사용하지 않고 변수나 메서드를 사용하면 기본적으로 private이 적용됩니다. 다만 클래스 내부에 또 하나의 클래스를 만드는 중첩 클래스의 경우에는 상위 클래스에서 private으로 변수를 정의했다고 하더라도 하위 클래스에서 상위 클래스로의 접근이 가능하게 됩니다. private이 내부에서는 접근을 허용하는 한정자이고 내부 하위 클래스는 해당 상위 클래스 안에 소속되어 있기 때문에 접근하는데 문제가 없는 것입니다.
8. 상속
어떤 클래스가 다른 클래스에서 사용되는 멤버들을 물려받는 것을 상속이라고 합니다.
class MyTestApp
{
static void Main(string[] args)
{
TestClass2 tc = new TestClass2();
tc.myField = 100;
}
}
class TestClass1
{
public int myField;
}
class TestClass2 : TestClass1
{
}
예제에서 TestClass1은 myField라는 필드를 가지고 있지만 TestClass2에는 없습니다. 하지만 TestClass 2로부터 생성된 객체에서는 TestClass1에 있는 myField에 접근하고 있습니다. 이 것이 가능한 이유는 TestClass2가 TestClass1을 'class TestClass2 : TestClass1' 구문을 통해서 '상속'받았기 때문입니다.
이 처럼 상속은 자신이 가지고 있는 어떠한 것을 다른 요소에게 물려주는 기능을 하고 있으며 상속받는 클래스를 파생 클래스(Derived Class), 상속하는 클래스를 기반(Base Class)라고 합니다.
앞서 클래스에서는 객체가 생성될 때 클래스의 '생성자'를 호출하고 소멸할때는 '소멸자'를 호출한다고 했는데 이는 상속에서도 동일하게 적용됩니다. B라는 클래스가 A라는 클래스로부터 상속된 경우 B클래스의 객체가 생성될때 기반 클래스에 해당하는 A클래스의 생성자를 먼저 호출한 후 뒤이어 B클래스의 생성자를 호출해 객체를 생성합니다. 객체가 소멸되는 경우는 역으로 파생 클래스의 소멸자를 먼저 호출하고 뒤이어 기반 클래스의 소멸자를 호출해 객체를 소멸합니다.
갑자기 생성자를 언급하는 이유는 클래스가 기본 생성자만을 가지고 있는 경우는 별 문제없지만 생성자를 호출할 때 어떠한 값을 넘겨줘야 하는 별도의 생성자가 존재하는 경우
class TestClass1
{
protected int myField;
public TestClass1(int i)
{
myField = i;
}
}
파생 클래스에서는 이러한 기반 클래스의 생성자를 호출할때 필요한 값을 전달할 수 있어야 하기 때문입니다.
파생 클래스에서 기반 클래스의 생성자를 호출해야 한다면 base키워드를 사용합니다.
class TestClass2 : TestClass1
{
public TestClass2(int i) : base(i)
{
}
}
base는 기반 클래스의 생성자 자체를 나타내므로 예제에서 처럼 자신이 받은 값을 base를 통해 그대로 넘겨주면 기반 클래스의 생성자를 통해 매개변수의 값을 전달할 수 있습니다.
어떤 경우는 내가 만든 클래스를 다른 클래스에서 상속하여 사용하지 못하게끔 하고 싶은 경우도 있는데 이럴 때는 sealed키워드를 사용해 클래스를 수식합니다.
sealed class TestClass1
{
protected int myField;
public TestClass1(int i)
{
myField = i;
}
}
따라서 예제에서 사용된 TestClass1 클래스는 더 이상 TestClass2처럼 TestClass1을 상속하여 사용할 수 없습니다.
9. is / as
아래와 같은 클래스가 있을 때
class Car
{
public int Speed;
}
다음 2개의 클래스에서 위 클래스를 기반 클래스로 하여 상속받은 경우
class Sedan : Car
{
}
class Truck : Car
{
}
Sedan과 Truck클래스는 태생이 Car클래스이므로 Sedan과 Truck클래스의 객체를 기반 클래스를 통해 생성하는 경우도 가능하게 됩니다.
class MyTestApp
{
static void Main(string[] args)
{
Car c = new Sedan();
c.Speed = 100;
}
}
이러한 문법은 파생 클래스를 모두 다뤄야 하는 경우 유용하게 사용될 수 있습니다. 예를 들어 자동차를 폐차하는 클래스를 만든다고 가정한다면 Sedan이나 Trcuk을 각각 처리하는 대신 Car라는 기반 클래스 하나만으로 구현이 가능한 것입니다.
class Scrap
{
public void Press(Car car)
{
//폐차
}
}
각 클래스들이 기반 클래스와 파생 클래스의 형태로 관계를 이루고 있을 때 유용하게 사용할 수 있는 연산자가 is와 as입니다. 우선 is는 객체가 지정한 클래스의 형식이 맞는지를 확인하고 bool형식으로 결과를 반환하는 연산자입니다.
class MyTestApp
{
static void Main(string[] args)
{
Sedan c = new Sedan();
bool b = c is Car;
WriteLine(b); //True
}
}
as는 지정한 클래스 형식으로 변환을 시도하고 가능하다면 변환된 객체를, 변환이 불가능하다면 null을 반환하는 연산자입니다.
class MyTestApp
{
static void Main(string[] args)
{
Sedan c = new Sedan();
Car car = c as Car;
if (car != null) {
car.Speed = 100;
WriteLine(car.Speed);
}
}
}
예제에서는 Sedan객체인 c를 Car객체로 변환하고 있는데 만약 실패한 경우를 대비해 if문에서 객체의 null여부를 확인하고 있습니다. 하지만 c는 Car로의 객체 변환이 가능하기에 Speed값을 부여하고 해당 값을 출력할 것입니다.
10. 다형성(Polymorphism)
객체가 여러 형태를 가질 수 있음을 '다형성'이라고 표현합니다. 이 다형성은 본래 기반 클래스에서 정의한 메서드를 변경하는 것으로 구현되는데 이 메서드 변경을 '오버 라이딩'이라고 합니다. 즉, 다형성은 메서드의 '오버 라이딩'을 통해서 구현된 되는 것입니다.
class Car
{
public virtual void Drive()
{
WriteLine("자동차 주행");
}
}
위 예제는 Car 클래스에서 Drive() 메서드를 구현한 것입니다. 메서드를 자세히 보면 virtual 키워드로 메서드가 수식되어 있는 것을 알 수 있는데 메서드에 virtual이 있다는 것은 파생 클래스에서 해당 메서드를 재정의할 수 있다는 것을 의미하는 것입니다.
Car 클래스에서 Dirve() 메서드는 자동차가 주행한다는 것을 처리하지만 이를 상속받는 클래스에서 자신만의 주행 처리를 수행해야 한다면 Drive() 메서드를 다음과 같은 방법으로 재정의 할 수 있습니다.
class Sedan : Car
{
public override void Drive()
{
base.Drive();
WriteLine("승용차 주행");
}
}
기반 클래스의 메서드를 재정의하려면 기반 클래스에서 메서드가 virtual로 정의되어 있어야 하며 이를 재정의 하는 파생 클래스에서는 override 키워드를 사용해 메서드를 정의해야 합니다.
참고로 virtual 메서드를 override로 재정의 하는 경우 필요하다면 base를 통해 기반 클래스의 본래 메서드를 호출해 줄 수도 있습니다.
11. new 메서드
파생 클래스에서 기반 클래스의 메서드를 감추고 자신의 메서드만 노출하는 것을 말합니다.
class Car
{
public void Drive()
{
WriteLine("자동차 주행");
}
}
class Sedan : Car
{
public new void Drive()
{
WriteLine("승용차 주행");
}
}
Car 클래스에서 Drive() 메서드를 구현하고 있지만 파생 클래스인 Sedan클래스에서 동일한 메서드를 new로 정의하였습니다. 이렇게 하면 기반 클래스인 Car 클래스의 Drive()를 노출하지 않게 되므로 '승용차 주행'이라는 문구만 나오게 됩니다.
오버 라이딩과 달리 단순히 기반 클래스의 메서드를 숨길뿐이므로 base를 통해 기반 클래스의 메서드를 호출하는 것 또한 불가능합니다.
12. 봉인 메서드
클래스가 상속되는 걸 방지하기 위해서 클래스를 sealed로 봉인할 수 있었는데 메서드도 동일한 처리가 가능합니다. 다만 기반 클래스가 아닌 파생 클래스 단계에서만 봉인이 가능합니다.
class Car
{
public virtual void Drive()
{
WriteLine("자동차 주행");
}
}
class Sedan : Car
{
public sealed override void Drive()
{
WriteLine("승용차 주행");
}
}
Car 클래스에서 정의된 Drive() 메서드를 Sedan클래스에서 오버 라이딩하고 있습니다. 이때 메서드를 sealed로 봉인하고 있는데 이렇게 하면 Sedan을 상속하는 다른 파생 클래스에서는 더 이상 Drive() 메서드를 재정의할 수 없게 됩니다.
class Truck : Sedan
{
public override void Drive() //오류 Drive() 메서드는 봉인됨
{
}
}
13. 읽기 전용 필드
클래스 안에서 최초로 한 번만 값을 정해주고 이후부터 해당 변수는 읽기만 가능하게 할 수 있는데 이를 '읽기 전용 필드'라고 합니다.
class Car
{
readonly int speed;
public Car(int i)
{
speed = i;
}
public void Drive(int i)
{
speed = i; //에러 값을 바꿀 수 없음
}
}
읽기 전용 필드는 readonly로 정의되며 예제에서는 speed변수가 읽기 전용 필드에 해당합니다. 이 변수는 오직 생성자를 통해서만 최초로 값이 할당되며 이후부터는 값을 바꿀 수 없습니다. 따라서 Drive() 메서드를 통해 값을 바꾸려는 시도는 컴파일 오류를 발생시키게 됩니다.
14. 분할 클래스
하나의 클래스 안에서 어떠한 객체의 표현을 모두 담기에 코드의 양이 너무 길어지는 경우라면 클래스를 Partial로 분리할 수 있습니다.
partial class Car
{
private int speed;
public void Drive(int i)
{
speed = i; //에러 값을 바꿀 수 없음
}
}
partial class Car
{
public void Stop()
{
speed = 0;
}
}
Car라는 클래스를 partial을 통해 2개의 클래스로 분리하여 분할 클래스를 구현하였습니다. 분할 클래스의 경우에는 클래스 이름이 같아야 하며 모두 partial 키워드를 통해 수식되어야 합니다.
코드로만 봤을 때 물리적으로 분리된 클래스처럼 보이지만 Car클래스는 컴파일 시 하나로 합쳐져 처리됩니다. 따라서 두 번째 Car클래스처럼 해당 클래스 내부에서 선언된 변수가 아니라 하더라도 다른 partial클래스에서 접근 가능한 변수라면 얼마든지 사용할 수 있습니다. 물론 메서드나 다른 필드 등도 모두 마찬가지입니다.
class MyTestApp
{
static void Main(string[] args)
{
Car c = new Car();
c.Drive(100);
c.Stop();
}
}
15. 확장 메서드
기존 클래스에서는 없던 새로운 메서드를 구현해 기존 클래스에 붙여 해당 클래스의 기능을 '확장'하는 것을 말합니다.
우선 확장 클래스는 다음과 같은 방법으로 만들 수 있는데
public static class MyExtention
{
public static int Plus(this int i, int j)
{
return i + j;
}
}
확장 메서드를 구현을 위해서는 우선 해당 메서드가 들어갈 클래스를 만들어야 하기에 임의의 이름으로 클래스를 생성하고 내부에 Plus()라는 확장 메서드를 구현하였습니다. 확장 메서드는 클래스와 메서드 모두 static으로 선언되어야 하며 매개변수 자리에 this를 통해서 해당 메서드를 등록할 대상 클래스 지정합니다.
예제에서는 확장대상의 클래스로 int 클래스를 지정하였으며 두 번째 매개변수부터가 실제 값이 전 될 되는 매개변수에 해당합니다.
위와 같이 확장 메서드를 작성하고 나면 아래와 같은 방법으로 확장메서드를 사용할 수 있습니다.
class MyTestApp
{
static void Main(string[] args)
{
int p = 10;
WriteLine(p.Plus(20));
}
}
int형 변수 p는 int형이므로 확장메서드를 통해 구현된 Plus() 메서드를 사용할 수 있습니다. 마치 기존 int형 클래스에 존재하던 메서드처럼 사용하는 것입니다.
확장 메서드는 기존에 클래스가 존재하는데 해당 클래스를 수정하기가 불가능하거나 곤란한 경우 유용하게 사용되는 방법입니다.
16. 구조체
클래스와 아주 비슷한 개념으로 클래스처럼 필드와 메서드를 가질 수 있습니다.
struct MyStruct
{
public int speed;
public void Drive()
{
WriteLine("주행");
}
}
보기에도 클래스와 상당히 유사하지만 개념적으로 가장 큰 차이점은 단순히 값을 다루고자 하는 경우만 구조체를 사용한다는 것입니다. 클래스는 현실세계의 객체를 추상화하여 객체의 설계도를 그려내는데 목적이 있지만 구조체는 그보다는 한결 더 가벼운 개념으로 통상 오로지 데이터를 다루기 위한 목적으로만 사용됩니다.
이런 개념적 차이로 인해 class와 struct처럼 문법적인 차이뿐만 아니라 구조체는 스택에 값이 할당되는 값 형식이라는 것과 인스턴스를 생성하지 않고도 사용 가능하다는 점, 그리고 클래스와 달리 상속이 불가능하다는 점등이 차이로 존재합니다.
특히 구조체가 값 형식이라는 점은 클래스와 가장 큰 차이점인데 복사의 경우에도 깊은 복사가 이루어지며 구조체의 사용이 종료되면 즉시 메모리에서 제거됩니다. 가비지 컬렉터의 별도 수거작업이 필요하지 않으므로 성능상 클래스보다 이점을 가져갈 수 있습니다.
예제에서는 필드와 메서드 모두 public으로 하였는데 보통의 경우 구조체는 클래스에서처럼 은닉성을 다루지 않기 때문에 다른 한정자가 사용되지 않으며 기본적으로 구조체가 선언되고 나면 내부의 필드를 모두 초기화해줘야 하기 때문에 public 이외에 다른 한정자 사용은 큰 의미가 없습니다.
class MyTestApp
{
static void Main(string[] args)
{
MyStruct ms;
ms.speed = 100;
ms.Drive();
}
}
필요하다면 구조체도 생성자를 가질 수 있습니다. 다만 구조체는 매개변수 없는 생성자를 만들 수 없는데 이러한 특징을 이용해 생성자를 통해서 구조체 내부의 필드를 초기화하기도 합니다.
class MyTestApp
{
static void Main(string[] args)
{
MyStruct ms = new MyStruct(100);
ms.Drive();
}
}
struct MyStruct
{
public int speed;
public MyStruct(int i)
{
speed = i;
}
public void Drive()
{
WriteLine("주행");
}
}
위에서 잠깐 읽기 전용 필드에 대해서 다뤄봤는데 같은 개념을 구조체에도 적용할 수 있습니다. 어려울 것 없이 그냥 구조체를 readonly키워드를 붙여 사용해 주면 됩니다.
readonly struct MyStruct
{
public readonly int speed;
public MyStruct(int i)
{
speed = i;
}
public void Drive()
{
WriteLine("주행");
}
}
일단 구조체를 readonly로 수식하게 되면 내부 필드도 모두 readonly로 선언되어야 합니다. 따라서 내부 필드는 생성자를 통해 초기화된 이후로는 해당 값을 변경할 수 없게 됩니다.
class MyTestApp
{
static void Main(string[] args)
{
MyStruct ms = new MyStruct(100);
ms.Drive();
}
}
17. 튜플
이제 까지 에제에서는 직접 구조체를 선언하고 사용했지만. NET Framework에서는 임의로 즉석에서 생성 가능한 구조체를 미리 정의해 둔 게 있는데 이렇게 미리 정의된 구조체를 '튜플'이라고 합니다.
var myData = (10, 20);
튜플은 괄호를 통해 2개 이상의 값을 전달하여 생성되며 System.ValueTuple 구조체를 기반으로 만들어지게 됩니다. 또한 각 필드는 ItemXX 형식의 필드를 통해 접근할 수 있습니다.
class MyTestApp
{
static void Main(string[] args)
{
var myData = (10, 20);
WriteLine(myData.Item1 + "---" + myData.Item2);
}
}
물론 원한다면 직접 필드 이름을 지정할 수도 있습니다.
class MyTestApp
{
static void Main(string[] args)
{
var myData = (myValue1 : 10, myValue2 : 20);
WriteLine(myData.myValue1 + "---" + myData.myValue2);
}
}
만들어진 튜플은 다음과 같이 생성과 반대의 방법으로 분해할 수도 있습니다.
class MyTestApp
{
static void Main(string[] args)
{
var myData = (myValue1 : 10, myValue2 : 20);
var (item1, item2) = myData;
WriteLine(item1 + "---" + item2);
}
}
분해할 때 필요 없는 필드가 있다면 해당 위치에 _(언더바) 문자를 사용해 무시할 수 있습니다.
class MyTestApp
{
static void Main(string[] args)
{
var myData = (myValue1 : 10, myValue2 : 20);
var (item1, _) = myData;
WriteLine(item1);
}
}
튜플을 분해하면 각 필드의 위치에 따라서 값이 분리되는데 이러한 특징을 이용하면 switch식 등에서 분해된 결과의 위치만으로 값을 비교할 수 있는 '위치 패턴 매칭'을 구현할 수 있습니다.
class MyTestApp
{
static void Main(string[] args)
{
var car = (type : "sedan", speed : 120);
var result = car switch {
("sedan", int speed) when speed > 100 => "승용차 과속",
("sedan", int speed) when speed <= 100 => "승용차 정상",
("truck", int speed) when speed > 80 => "화물차 과속",
("truck", int speed) when speed <= 80 => "화물차 정상",
_ => "알 수 없음"
};
WriteLine(result); //승용차 과속
}
}
swtch 식은 car 튜플을 분해하여 각 필드의 값을 위치에 따라 비교하고 있고 그 결과에 따라 다른 값을 반환할 것입니다.
'.NET > C#' 카테고리의 다른 글
[C#] 프로퍼티(Property) (0) | 2021.10.07 |
---|---|
[C#] 인터페이스와 추상클래스 (2) | 2021.10.07 |
[C#] 메서드 (0) | 2021.09.27 |
[C#] 제어문 (0) | 2021.09.24 |
[C#] 연산자 (0) | 2021.09.23 |