클래스는 복합 데이터 형식이다. 객체지향 관점에서는 객체를 위한 청사진(집약체)이며, 코드 관점에서는 하나 이상의 데이터 형식을 조합해서 만드는 복합 데이터 형식
클래스를 사용하기 위해서는 먼저 선언해야 한다. 클래스 선언은 다음과 같은 기본 형태를 가진다.
class 클래스_이름
{
// 데이터와 메서드
}
실제 예시로 Cat
클래스를 선언할 수 있다. 이 클래스는 Name
(이름)과 Color
(색상) 필드, 그리고 Meow()
(야옹거리는) 메서드를 가진다.
class Cat
{
public string Name;
public string Color;
public void Meow()
{
Console.WriteLine($"{Name}: 야옹");
}
}
클래스가 선언되면, 이 클래스의 인스턴스(객체)를 new
키워드를 사용하여 생성할 수 있습니다. 예를 들어, Cat
클래스의 kitty
라는 객체를 생성하는 코드는 다음과 같다.
Cat kitty = new Cat();
kitty.Color = "하얀색";
kitty.Name = "키티";
kitty.Meow(); // 출력: 키티: 야옹
이렇게 생성된 객체는 클래스에 정의된 데이터(필드)와 메서드를 가진다.
클래스 안에 선언된 변수를 필드, 함수를 메서드라고 한다. 이들은 클래스의 멤버
멤버는 크게 인스턴스 멤버와 정적 멤버로 나눌 수 있다.
new
키워드를 통해 인스턴스(객체)를 생성해야만 접근하고 사용할 수 있는 멤버로, 위 Cat
클래스의 Name
, Color
필드와 Meow()
메서드가 인스턴스 멤버의 예시static
): 인스턴스를 만들지 않고도 클래스 이름만으로 바로 접근하고 사용할 수 있는 멤버로, 정적 멤버는 static
키워드를 사용하여 선언한다. 예를 들어, 모든 객체가 공유하는 값을 저장하거나 객체와 독립적인 기능을 수행할 때 사용정적 필드의 예시는 다음과 같습니다. Global.Count
필드는 인스턴스 생성 없이 Global.Count
로 접근한다.
class Global
{
public static int Count = 0;
}
class MainApp
{
static void Main(string[] args)
{
Console.WriteLine($"Global.Count : {Global.Count}"); // 인스턴스 없이 접근
new ClassA(); // 내부에서 Global.Count 증가
new ClassB(); // 내부에서 Global.Count 증가
new ClassB(); // 내부에서 Global.Count 증가
new ClassA(); // 내부에서 Global.Count 증가
Console.WriteLine($"Global.Count : {Global.Count}");
}
}
// ClassA와 ClassB는 각자의 생성자에서 Global.Count를 증가시키는 코드가 있다고 가정
정적 메서드의 예시는 다음과 같습니다. MyClass.StaticMethod()
는 인스턴스 생성 없이 클래스 이름으로 바로 호출 가능하다.
class MyClass
{
public static void StaticMethod() { /* ... */ }
public void InstanceMethod() { /* ... */ }
}
// 호출 예시
MyClass.StaticMethod(); // 인스턴스를 만들지 않고도 바로 호출 가능
MyClass obj = new MyClass();
obj.InstanceMethod(); // 인스턴스를 만들어야 호출 가능
생성자(Constructor)는 객체가 생성될 때 자동으로 호출되어 필드를 초기화하는 등의 작업을 수행하는 메서드, 생성자의 이름은 클래스 이름과 같으며, 반환 형식을 선언하지 않는다.
매개변수가 없는 생성자를 기본 생성자라고 한다. 프로그래머가 생성자를 하나라도 정의하지 않으면 C# 컴파일러가 자동으로 매개변수 없는 기본 생성자를 제공한다. 하지만 프로그래머가 생성자를 하나라도 정의하면, 컴파일러는 기본 생성자를 자동으로 제공하지 않는다.
Cat
클래스의 생성자 예시
class Cat
{
public string Name;
public string Color;
public Cat() // 기본 생성자
{
Name = "";
Color = "";
}
public Cat(string _Name, string _Color) // 매개변수가 있는 생성자
{
Name = _Name;
Color = _Color;
}
// ... Meow() 메서드 등
}
객체 생성 시점에 생성자가 호출된다.
Cat kitty = new Cat(); // 기본 생성자 호출
Cat nabi = new Cat("나비", "갈색"); // 매개변수 있는 생성자 호출
종료자(Finalizer)는 객체가 소멸될 때 호출된다. 종료자는 C#에서는 잘 사용되지 않으며, 일반적으로 가비지 수집기가 객체를 메모리에서 회수하는 시점에 호출될 수 있습니다. 종료자 이름은 클래스 이름 앞에 ~
를 붙이며, 매개변수나 반환 형식을 선언하지 않는다.
class Cat
{
// ... 생성자, 필드, 메서드 ...
~Cat() // 종료자
{
Console.WriteLine($"{Name} : 잘가!");
}
}
객체를 복사하는 방법에는 얕은 복사(Shallow Copy)와 깊은 복사(Deep Copy)가 있다.
MyClass target = source;
는 얕은 복사의 한 형태로 source
와 target
변수는 동일한 MyClass
인스턴스를 참조한다.class MyClass
{
public int MyField1;
public int MyField2;
}
MyClass source = new MyClass();
source.MyField1 = 10;
source.MyField2 = 20;
MyClass target = source; // 얕은 복사 - 동일 인스턴스 참조
target.MyField2 = 30;
Console.WriteLine($"{source.MyField1}, {source.MyField2}"); // 출력: 10, 30 - source에서도 MyField2가 변경됨
Console.WriteLine($"{target.MyField1}, {target.MyField2}"); // 출력: 10, 30
Clone()
메서드를 직접 구현하거나 ICloneable
인터페이스를 활용하여 구현한다.class MyClass
{
public int MyField1;
public int MyField2;
public MyClass DeepCopy() // 깊은 복사를 위한 메서드 직접 구현
{
MyClass newCopy = new MyClass();
newCopy.MyField1 = this.MyField1;
newCopy.MyField2 = this.MyField2;
return newCopy;
}
}
MyClass source = new MyClass();
source.MyField1 = 10;
source.MyField2 = 20;
MyClass target = source.DeepCopy(); // 깊은 복사
target.MyField2 = 30;
Console.WriteLine($"{source.MyField1}, {source.MyField2}"); // 출력: 10, 20 - source의 MyField2는 변경되지 않음
Console.WriteLine($"{target.MyField1}, {target.MyField2}"); // 출력: 10, 30
ICloneable
인터페이스의 Clone()
메서드를 구현하는 것이 깊은 복사를 위한 좋은 방법
this
키워드this
키워드는 객체 자신을 가리킨다. 주로 다음과 같은 상황에서 사용된다.
this.필드이름
형태로 인스턴스 필드를 명확하게 지칭할 수 있다.class Employee
{
private string Name; // 인스턴스 필드
public void SetName(string Name) // 매개변수 이름이 필드 이름과 같음
{
this.Name = Name; // this.Name은 인스턴스 필드를, Name은 매개변수를 가리킴
}
public string GetName()
{
return this.Name; // 인스턴스 필드 접근
}
}
this(...)
형태로 사용한다.class MyClass
{
int a, b, c;
public MyClass() // 생성자 1
{
this.a = 5425;
}
public MyClass(int b) : this() // 생성자 2: 생성자 1 호출
{
this.b = b;
}
public MyClass(int b, int c) : this(b) // 생성자 3: 생성자 2 호출
{
this.c = c;
}
}
접근 한정자는 클래스 멤버(필드, 메서드, 속성 등)나 클래스 자체에 대한 접근 수준을 제어한다. C# 객체지향 프로그래밍의 3대 특성 중 하나인 캡슐화(Encapsulation)와 관련이 깊습니다. 3대 특성은 은닉성(Encapsulation), 상속성(Inheritance), 다형성(Polymorphism)
주요 접근 한정자는 다음과 같다:
private
: 해당 클래스 내부에서만 접근 가능.protected
: 해당 클래스 내부 또는 이 클래스를 상속받은 파생 클래스에서 접근 가능.internal
: 동일 어셈블리 내에서만 접근 가능.public
: 어디서든 접근 가능.접근 한정자를 명시하지 않으면 기본적으로 private
과 같은 접근 수준을 가집니다 (클래스 내부에 선언된 멤버의 경우).
class MyClass
{
private int MyField_1; // 클래스 내부에서만 접근
protected int MyField_2; // 클래스 내부 또는 파생 클래스에서 접근
int MyField_3; // private와 같은 접근 수준
public int MyMethod_1() { /* ... */ } // 어디서든 접근
internal void MyMethod_2() { /* ... */ } // 동일 어셈블리 내에서 접근
}
캡슐화는 클래스 내부 데이터를 보호하고, 외부에서는 허용된 메서드를 통해서만 접근 및 수정하도록 하는 것이다. 예를 들어, WaterHeater
클래스의 temperature
필드를 protected
로 선언하고, 온도 설정은 유효성 검사를 수행하는 public
메서드 SetTemperature
를 통해서만 가능하게 함으로써 잘못된 값의 직접적인 접근을 막는다.
class WaterHeater
{
protected int temperature; // protected 필드
public void SetTemperature(int temperature) // public 메서드
{
if (temperature < -5 || temperature > 42) // 유효성 검사
{
throw new Exception("Out of temperature range"); // 예외 발생
}
this.temperature = temperature; // 필드 값 설정
}
// ...
}
상속은 기존 클래스(기반 클래스, 부모 클래스)의 특징을 물려받아 새로운 클래스(파생 클래스, 자식 클래스)를 만드는 기능이다. 이를 통해 코드 재사용성을 높일 수 있습니다. C#에서는 : 기반클래스이름
형태로 상속을 표현한다.
파생 클래스는 기반 클래스의 모든 멤버를 물려받지만, private
으로 선언된 멤버는 예외
class Base // 기반 클래스
{
public void BaseMethod()
{
Console.WriteLine("BaseMethod()");
}
}
class Derived : Base // Derived 클래스는 Base 클래스를 상속
{
// 별도로 선언하지 않아도 BaseMethod()를 가짐
}
파생 클래스는 base
키워드를 사용하여 기반 클래스의 멤버에 접근할 수 있습니다. 특히 파생 클래스의 생성자에서 기반 클래스의 생성자를 호출할 때 유용하게 사용된다.
class Base
{
protected string Name;
public Base(string Name) // 기반 클래스 생성자
{
this.Name = Name;
}
}
class Derived : Base
{
public Derived(string Name) : base(Name) // base 키워드로 기반 클래스 생성자 호출
{
Console.WriteLine($"Derived('{this.Name}')");
}
}
기반 클래스 작성자가 파생 클래스의 상속을 막고 싶다면 sealed
키워드를 클래스 선언 앞에 붙일 수 있다. sealed
클래스는 더 이상 상속될 수 없다.
상속 관계에 있는 클래스 객체 간에는 형식 변환이 가능하다.
class Mammal { public void Nurse() { /* ... */ } }
class Dog : Mammal { public void Bark() { /* ... */ } }
Mammal mammal = new Dog(); // 업캐스팅 (암시적)
mammal.Nurse(); // Mammal의 멤버는 접근 가능
// mammal.Bark(); // Dog의 멤버는 접근 불가
다운캐스팅(Downcasting): 기반 클래스 형식의 객체를 파생 클래스 형식으로 변환하는 것이다. 이는 명시적으로(수동으로) 수행해야 하며, 실패할 가능성이 있어 예외가 발생할 수 있다. 안전한 다운캐스팅을 위해 is
연산자와 as
연산자를 사용할 수 있다.
is
연산자: 객체가 특정 형식과 호환되는지 확인합니다. true
또는 false
를 반환합니다.as
연산자: 객체를 특정 형식으로 변환합니다. 변환에 성공하면 해당 형식의 객체를, 실패하면 null
을 반환합니다.Mammal mammal = new Dog(); // 업캐스팅
if (mammal is Dog) // is 연산자로 형식 확인
{
Dog dog = (Dog)mammal; // 명시적 다운캐스팅
dog.Bark();
}
Mammal mammal2 = new Cat(); // Cat도 Mammal 상속 (예시)
Cat cat = mammal2 as Cat; // as 연산자로 형식 변환 시도
if (cat != null) // 변환 성공 여부 확인
{
cat.Meow();
} else {
Console.WriteLine("mammal2 is not a Cat");
}
다형성(Polymorphism)은 객체지향의 3대 특성 중 하나로, 같은 형식의 객체가 상황에 따라 다른 행위를 하는 것을 의미한다. 다형성을 구현하는 주요 방법 중 하나가 오버라이딩(Overriding)이다.
오버라이딩은 기반 클래스에서 선언된 virtual
메서드를 파생 클래스에서 override
키워드를 사용하여 재정의하는 것아다. 이렇게 재정의된 메서드는 파생 클래스 객체를 기반 클래스 참조 변수로 가리킬 때(업캐스팅 시) 파생 클래스에서 재정의된 메서드가 호출된다.
class ArmorSuite
{
public virtual void Initialize() // virtual 메서드 - 오버라이딩 가능
{
Console.WriteLine("Armored");
}
}
class IronMan : ArmorSuite
{
public override void Initialize() // override 키워드로 재정의
{
base.Initialize(); // 기반 클래스의 Initialize() 호출
Console.WriteLine("Repulsor Rays Armed");
}
}
class WarMachine : ArmorSuite
{
public override void Initialize() // override 키워드로 재정의
{
base.Initialize(); // 기반 클래스의 Initialize() 호출
Console.WriteLine("Double-Barrel Cannons Armed");
Console.WriteLine("Micro-Rocket Launcher Armed");
}
}
오버라이딩은 객체의 실제 형식에 따라 호출되는 메서드가 달라지게 하여 다형성을 구현한다.
ArmorSuite armorsuite = new ArmorSuite();
armorsuite.Initialize(); // 출력: Armored
ArmorSuite ironman = new IronMan(); // 업캐스팅
ironman.Initialize(); // 출력: Armored\nRepulsor Rays Armed (IronMan에서 오버라이딩된 메서드 호출)
ArmorSuite warmachine = new WarMachine(); // 업캐스팅
warmachine.Initialize(); // 출력: Armored\nDouble-Barrel Cannons Armed\nMicro-Rocket Launcher Armed (WarMachine에서 오버라이딩된 메서드 호출)
참고로, private
으로 선언된 메서드는 파생 클래스에서 오버라이딩할 수 없다.
파생 클래스에서 기반 클래스의 메서드와 이름, 반환 형식, 매개변수 목록이 동일한 메서드를 new
키워드를 사용하여 새로 선언하면, 이는 기반 클래스의 메서드를 숨기는(Hiding) 것이다. 오버라이딩과 달리, new
키워드로 숨겨진 메서드는 객체의 참조 변수 형식에 따라 호출되는 메서드가 달라진다.
class Base
{
public void MyMethod() { Console.WriteLine("Base.MyMethod()"); }
}
class Derived : Base
{
public new void MyMethod() { Console.WriteLine("Derived.MyMethod()"); } // 기반 클래스 메서드 숨기기 (new 키워드 사용)
}
호출 시점의 예시
Derived derived = new Derived();
derived.MyMethod(); // 출력: Derived.MyMethod() (Derived 형식 참조 변수)
Base baseOrDerived = new Derived(); // 기반 클래스 형식 참조 변수
baseOrDerived.MyMethod(); // 출력: Base.MyMethod() (Base 형식 참조 변수 - 숨겨진 기반 클래스 메서드 호출)
파생 클래스에서 오버라이딩한 메서드를 더 이상 하위 클래스에서 재정의하지 못하도록 봉인(Sealing)할 수 있다. 이는 sealed override
키워드를 함께 사용하여 선언한다.
class Base
{
public virtual void SealMe() { /* ... */ }
}
class Derived : Base
{
public sealed override void SealMe() // 오버라이딩된 메서드를 봉인
{
// ... 이 메서드는 더 이상 재정의할 수 없음
}
}
class WantToOverride : Derived
{
// public override void SealMe() { /* ... */ } // 여기서 SealMe()를 재정의하려고 하면 컴파일 오류 발생
}
readonly
)readonly
필드는 필드 선언 시 또는 생성자 안에서만 초기화할 수 있으며, 한 번 초기화된 후에는 값을 변경할 수 없다.
class Configuration
{
private readonly int min; // 읽기 전용 필드 - 생성자나 선언 시 초기화
private readonly int max;
public Configuration(int v1, int v2) // 생성자
{
min = v1; // 생성자 안에서 초기화 가능
max = v2; // 생성자 안에서 초기화 가능
}
public void ChangeMax(int newMax)
{
// max = newMax; // 생성자 외부에서 값을 변경하려 하면 컴파일 오류 발생
}
}
상수(const
)와 비슷하지만, 상수는 선언과 동시에 초기화해야 하고 정적(static) 멤버로만 선언할 수 있는 반면, readonly
는 인스턴스별로 다른 값을 가질 수 있으며 생성자에서 초기화가 가능하다는 차이가 있다.
중첩 클래스는 다른 클래스 안에 정의된 클래스이다. 중첩 클래스는 자신을 감싸고 있는 외부 클래스(Outer Class)의 private
멤버에도 접근할 수 있다. 반대로 외부 클래스는 중첩 클래스의 private
멤버에 직접 접근할 수 없다.
class OuterClass // 외부 클래스
{
private int OuterMember; // 외부 클래스의 private 멤버
class NestedClass // 중첩 클래스
{
public void DoSomething()
{
OuterClass outer = new OuterClass(); // 외부 클래스 인스턴스 생성
outer.OuterMember = 10; // 중첩 클래스에서 외부 클래스의 private 멤버 접근 가능
}
}
}
중첩 클래스는 외부에서 접근하기 어렵거나, 외부 클래스의 기능과 매우 밀접하게 관련되어 있을 때 사용된다. 외부 클래스 밖에서는 중첩 클래스를 외부클래스이름.중첩클래스이름
형태로 참조해야 한다.
분할 클래스는 하나의 클래스를 여러 개의 물리적인 소스 파일로 나누어 정의할 수 있게 해주는 기능이다. 각 파일에 partial
키워드를 사용하여 클래스 정의의 일부임을 명시한다. 컴파일 시에는 이 파일들이 합쳐져 하나의 완전한 클래스가 된다.
// 파일 1: MyClass_Part1.cs
partial class MyClass // partial 키워드 사용
{
public void Method1() { Console.WriteLine("Method1"); }
public void Method2() { Console.WriteLine("Method2"); }
}
// 파일 2: MyClass_Part2.cs
partial class MyClass // 동일한 클래스 이름, partial 키워드 사용
{
public void Method3() { Console.WriteLine("Method3"); }
public void Method4() { Console.WriteLine("Method4"); }
}
이렇게 분할된 클래스는 마치 하나의 클래스처럼 사용된다. 분할 클래스는 특히 자동 생성 코드(예: UI 디자이너에서 생성되는 코드)와 개발자가 직접 작성하는 코드를 분리하여 관리할 때 유용하다.
확장 메서드는 기존 클래스의 소스 코드를 수정하지 않고도 새로운 메서드를 추가하는 것처럼 보이게 하는 기능이다. 확장 메서드는 public static
클래스에 public static
메서드로 정의하며, 첫 번째 매개변수 앞에 this
키워드를 붙여 해당 메서드를 확장할 클래스 또는 형식을 지정한다.
namespace MyExtension
{
public static class IntegerExtension // public static 클래스
{
public static int Square(this int myInt) // public static 메서드, 첫 매개변수 앞에 this
{
return myInt * myInt;
}
public static int Power(this int myInt, int exponent) // public static 메서드
{
int result = myInt;
for (int i = 1; i < exponent; i++)
{
result *= myInt;
}
return result;
}
}
}
확장 메서드를 사용하려면 해당 확장 메서드가 정의된 네임스페이스를 using
지시문으로 가져와야 한다. 마치 원래 클래스의 멤버 메서드인 것처럼 .메서드이름()
형태로 호출할 수 있다.
using MyExtension; // 확장 메서드 네임스페이스 가져오기
int a = 2;
Console.WriteLine(a.Square()); // 출력: 4 - int 형식에 Square() 메서드가 추가된 것처럼 사용
Console.WriteLine(3.Power(4)); // 출력: 81 - int 형식에 Power() 메서드가 추가된 것처럼 사용
string 클래스에도 Append 확장 메서드를 추가하는 예시가 소스에 포함되어 있다.
구조체(struct
)는 클래스와 비슷하게 필드, 메서드, 속성 등을 가질 수 있지만, 클래스와 달리 값 형식(Value Type)이고, 클래스는 참조 형식(Reference Type)이다.
주요 특징 및 클래스와의 차이점은 다음과 같다.
new
키워드: 구조체는 new
키워드 없이도 선언과 동시에 사용할 수 있다 (필드 초기화는 필요). new
를 사용하면 생성자가 호출된다.public
접근 수준을 가진다 (클래스는 private
).object
형식을 암시적으로 상속하며 인터페이스는 구현 가능하다.)readonly
구조체: 구조체 자체를 readonly
로 선언하여 모든 필드를 읽기 전용으로 만들 수 있다. 이 경우 필드는 선언 시 또는 생성자에서만 초기화할 수 있으며, readonly
구조체의 메서드는 필드 값을 변경할 수 없다.struct MyStruct
{
public int MyField1;
public int MyField2;
// ... 메서드 가능
}
MyStruct s; // new 없이 선언 가능
s.MyField1 = 1;
s.MyField2 = 2;
MyStruct t = s; // s의 값이 t로 복사됨
t.MyField1 = 3;
// s.MyField1은 1, s.MyField2는 2 그대로 유지
// t.MyField1은 3, t.MyField2는 2
readonly struct
의 예시입니다.
readonly struct ImmutableStruct // readonly 구조체
{
public readonly int ImmutableField; // readonly 필드
// public int MutableField; // 오류 - readonly 구조체에는 readonly 필드만 가능
public ImmutableStruct(int initValue) // 생성자에서 초기화 가능
{
ImmutableField = initValue;
}
// ... 이 구조체의 메서드는 ImmutableField 값을 변경할 수 없습니다
}
튜플은 여러 개의 요소를 하나의 객체처럼 묶어서 사용할 수 있는 간단한 데이터 구조이다. 주로 메서드가 여러 값을 반환하거나 임시로 여러 데이터를 묶어 사용할 때 유용하다. ()
괄호 안에 요소를 나열하여 생성한다.
var tuple1 = (123, 789); // 요소 이름 없이 사용
Console.WriteLine($"{tuple1.Item1}, {tuple1.Item2}"); // 출력: 123, 789 - 기본 이름 ItemN으로 접근
var tuple2 = (Name: "박상현", Age: 17); // 요소에 이름 지정 가능
Console.WriteLine($"{tuple2.Name}, {tuple2.Age}"); // 출력: 박상현, 17 - 지정한 이름으로 접근
var (name, age) = tuple2; // 튜플 분해(Deconstruction) - 각 요소를 개별 변수에 할당
Console.WriteLine($"{name}, {age}"); // 출력: 박상현, 17
튜플은 값 형식이며, C# 컴파일러가 형식을 추론하여 var
키워드로 선언하기 편리하다. 명명되지 않은 튜플과 명명된 튜플은 할당이 가능하며, 요소의 형식과 개수만 일치하면 된다.