Class

Class

class
this
instance
Published

April 6, 2025

Abstract

객체지향 프로그래밍(OOP)은 객체 간 상호작용을 기반으로 소프트웨어를 설계하는 프로그래밍 패러다임으로, 캡슐화, 추상화, 상속, 다형성을 핵심 개념으로 하여 재사용성, 유지보수성 및 확장성을 높이는 데 기여한다. 클래스는 객체를 생성하기 위한 설계도이며, 필드와 메소드 등으로 구성되고, 실제 메모리에 생성된 객체와 구분된다. 객체 생성 시에는 생성자를 사용하여 초기화하며, 객체 소멸 시 자원을 관리하기 위해 종료자(finalizer) 또는 IDisposable 인터페이스를 활용할 수 있다. 클래스 내부 멤버는 접근 한정자를 통해 접근성을 관리할 수 있고, 정적 멤버는 클래스 전체가 공유하며 인스턴스 멤버는 개별 객체마다 다른 값을 가질 수 있다. 클래스의 기능을 확장하는 방법으로는 상속, 오버라이딩을 통한 다형성 구현, 메소드 숨기기 등이 있으며, this 키워드는 현재 객체를 명시적으로 참조할 때 사용된다. 그 외에도 클래스 정의를 나누어 관리하는 분할 클래스(partial class), 기존 클래스에 메소드를 추가하는 확장 메소드(extension method), 데이터 복사 시 얕은 복사와 깊은 복사, 읽기 전용 필드, 중첩 클래스 등 다양한 구조가 제공되며, 값 형식으로는 구조체와 튜플 등을 통해 가볍고 효율적인 데이터 처리가 가능하다.

객체지향 프로그래밍과 클래스

객체지향 프로그래밍(Object-Oriented Programming, OOP)은 프로그램을 객체들의 집합으로 바라보고, 객체 간의 상호작용을 통해 프로그램을 개발하는 프로그래밍 패러다임입니다. OOP는 코드의 재사용성, 유지보수성, 확장성을 높이는 데 효과적이며, 현대 소프트웨어 개발에서 널리 사용되고 있습니다.

OOP의 핵심 개념은 다음과 같습니다.

  • 캡슐화(Encapsulation): 데이터와 데이터를 처리하는 메소드를 하나의 객체 안에 묶는 것입니다. 캡슐화를 통해 데이터의 무결성을 보호하고, 외부로부터의 불필요한 접근을 제한할 수 있습니다.
  • 추상화(Abstraction): 객체의 핵심 기능만을 드러내고, 불필요한 구현 details은 숨기는 것입니다. 추상화를 통해 복잡성을 줄이고, 사용자가 객체를 더 쉽게 사용할 수 있도록 합니다.
  • 상속(Inheritance): 기존 클래스의 속성과 기능을 물려받아 새로운 클래스를 만드는 것입니다. 상속을 통해 코드 재사용성을 높이고, 클래스 간의 계층 구조를 형성할 수 있습니다.
  • 다형성(Polymorphism): 하나의 인터페이스나 메소드가 여러 형태를 가질 수 있는 것을 의미합니다. 다형성을 통해 유연하고 확장 가능한 코드를 작성할 수 있습니다.

클래스(Class)는 OOP에서 객체를 만들기 위한 설계도 또는 템플릿입니다. 클래스는 객체의 속성(데이터)을 나타내는 필드(Field) 와 객체의 행동(기능)을 나타내는 메소드 (Method) 로 구성됩니다. 객체(Object)는 클래스를 기반으로 실제로 메모리에 생성된 실체입니다. 클래스는 단지 설계도일 뿐이지만, 객체는 실제로 데이터를 저장하고 메소드를 실행할 수 있는 존재입니다. 하나의 클래스로부터 여러 개의 객체를 생성할 수 있습니다.

// 클래스 선언: Car 라는 클래스 정의
public class Car
{
    public string Color;
    public void Drive()
    {
        Console.WriteLine("자동차가 주행합니다.");
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        Car myCar = new Car();
        myCar.Color = "Red";
        Console.WriteLine($"내 차의 색상은 {myCar.Color} 입니다.");
        myCar.Drive();
    }
}

클래스의 선언과 객체의 생성

클래스 선언은 class 키워드를 사용하여 정의합니다. 기본적인 클래스 선언 구조는 다음과 같습니다.

[접근 한정자] class 클래스이름
{
    // 클래스 멤버 (필드, 속성, 생성자, 메소드 등)
}
  • 접근 한정자(Access Modifier): 클래스의 접근 수준을 결정합니다(예: public, private, internal 등).
  • class 키워드: 클래스를 선언하는 것을 나타냅니다.
  • 클래스이름: 클래스의 이름을 지정합니다. 클래스 이름은 일반적으로 명사로 시작하고, PascalCase 명명 규칙을 따르는 것이 관례입니다.
  • 클래스 멤버: 클래스를 구성하는 요소들입니다. 필드, 속성, 생성자, 메소드 등이 클래스 멤버에 해당합니다.

객체 생성은 new 키워드와 생성자를 사용하여 수행합니다. 객체 생성 과정은 다음과 같습니다.

클래스이름 객체이름 = new 생성자();
  • 클래스이름: 생성할 객체의 클래스 이름을 지정합니다.
  • 객체이름: 생성된 객체를 참조할 변수 이름을 지정합니다. 객체 이름은 일반적으로 camelCase 명명 규칙을 따르는 것이 관례입니다.
  • new 키워드: 객체를 메모리에 할당하는 역할을 합니다.
  • 생성자 (Constructor): 객체가 생성될 때 초기화를 담당하는 특별한 메소드입니다.
public class Dog
{
    public string Name;
    public int Age;
    public void Bark()
    {
        Console.WriteLine("멍멍!");
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        Dog myDog = new Dog();
        myDog.Name = "Happy";
        myDog.Age = 3;
        Console.WriteLine($"내 강아지 이름은 {myDog.Name}이고, 나이는 {myDog.Age}살 입니다.");
        myDog.Bark();

        Dog yourDog = new Dog(); // 또 다른 Dog 객체 생성
        yourDog.Name = "Buddy";
        yourDog.Age = 5;
        Console.WriteLine($"네 강아지 이름은 {yourDog.Name}이고, 나이는 {yourDog.Age}살 입니다.");
        yourDog.Bark();
    }
}

위 예시에서 myDogyourDog은 모두 Dog 클래스의 객체이지만, 각각 독립적인 객체이며 서로 다른 속성 값을 가질 수 있습니다.

객체의 삶과 죽음에 대하여: 생성자와 종료자

생성자 (Constructor) 는 객체가 생성될 때 자동으로 호출되는 특별한 메소드입니다. 생성자의 주요 역할은 객체를 초기화하는 것입니다. 예를 들어, 객체의 필드 값을 초기화하거나, 객체 생성 시 필요한 리소스를 할당하는 등의 작업을 수행합니다.

  • 생성자는 클래스 이름과 동일한 이름을 가지며, 반환 형식을 가지지 않습니다.
  • 클래스는 여러 개의 생성자를 가질 수 있습니다. 이를 생성자 오버로딩 (Constructor Overloading) 이라고 합니다. 생성자 오버로딩을 통해 객체 생성 시 다양한 방법으로 초기화할 수 있습니다.
  • 클래스에 명시적으로 생성자를 정의하지 않으면, 컴파일러는 매개변수가 없는 기본 생성자 (Default Constructor) 를 자동으로 제공합니다.

종료자 (Finalizer) 는 객체가 더 이상 사용되지 않고 가비지 컬렉션에 의해 메모리에서 해제되기 직전에 호출되는 특별한 메소드입니다. 종료자의 주요 역할은 객체가 사용하던 리소스를 해제하는 것입니다. 예를 들어, 파일 핸들, 네트워크 연결, 데이터베이스 연결 등을 닫는 작업을 수행합니다.

  • 종료자는 클래스 이름 앞에 ~ 기호를 붙여 정의합니다.
  • 종료자는 매개변수를 가질 수 없으며, 접근 한정자를 사용할 수 없습니다.
  • 종료자는 가비지 컬렉터에 의해 언제 호출될지 예측하기 어렵기 때문에, 중요한 리소스 해제 작업은 Dispose() 메소드와 같은 명시적인 리소스 해제 방법을 사용하는 것이 권장됩니다.
  • C# 에서는 종료자 대신 IDisposable 인터페이스를 구현하고 Dispose() 메소드를 사용하는 패턴이 더 일반적입니다. using 구문을 사용하여 IDisposable 객체를 안전하게 사용할 수도 있습니다.

예시 (생성자):

public class Book
{
    public string Title;
    public string Author;

    // 기본 생성자
    public Book()
    {
        Title = "제목 없음";
        Author = "작자 미상";
    }

    // 매개변수를 받는 생성자 (생성자 오버로딩)
    public Book(string title, string author)
    {
        Title = title;
        Author = author;
    }

    public void DisplayInfo()
    {
        Console.WriteLine($"제목: {Title}, 저자: {Author}");
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        Book book1 = new Book(); // 기본 생성자 호출
        book1.DisplayInfo(); // 제목: 제목 없음, 저자: 작자 미상

        Book book2 = new Book("C# 프로그래밍", "홍길동"); // 매개변수를 받는 생성자 호출
        book2.DisplayInfo(); // 제목: C# 프로그래밍, 저자: 홍길동
    }
}

예시 (종료자 - 사용 권장되지 않음):

public class MyResource
{
    // 리소스 (예: 파일 핸들, 데이터베이스 연결 등)
    private System.IO.StreamWriter fileWriter;

    public MyResource(string filename)
    {
        fileWriter = new System.IO.StreamWriter(filename);
        Console.WriteLine("리소스 생성됨");
    }

    // 종료자 (Finalizer) - 사용 권장되지 않음
    ~MyResource()
    {
        if (fileWriter != null)
        {
            fileWriter.Close();
            fileWriter.Dispose();
            Console.WriteLine("리소스 해제됨 (종료자)");
        }
    }

    public void WriteData(string data)
    {
        if (fileWriter != null)
        {
            fileWriter.WriteLine(data);
        }
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        MyResource resource = new MyResource("output.txt");
        resource.WriteData("데이터 기록");

        // using 구문 또는 Dispose() 메소드를 사용하여 리소스 해제를 명시적으로 처리하는 것이 좋습니다.
        // 종료자는 가비지 컬렉션에 의해 언제 호출될지 예측하기 어렵습니다.
    }
}

정적 필드와 메소드

정적 (Static) 필드 와 정적 (Static) 메소드 는 클래스의 객체가 아닌 클래스 자체에 속하는 멤버입니다.

  • 정적 멤버는 static 키워드를 사용하여 선언합니다.
  • 정적 필드는 클래스의 모든 객체가 공유하는 데이터를 저장하는 데 사용됩니다. 클래스의 모든 객체는 동일한 정적 필드 값을 공유하며, 정적 필드 값이 변경되면 모든 객체에 반영됩니다.
  • 정적 메소드는 객체의 상태에 의존하지 않는 유틸리티 기능을 제공하는 데 사용됩니다. 정적 메소드는 객체를 생성하지 않고 클래스 이름을 통해 직접 호출할 수 있습니다.
  • 정적 멤버는 클래스 이름으로 직접 접근합니다. (예: ClassName.StaticField, ClassName.StaticMethod())
  • 정적 멤버는 객체가 생성되기 전에도 메모리에 할당됩니다.

인스턴스 필드 와 인스턴스 메소드 는 클래스의 각 객체에 속하는 멤버입니다.

  • 인스턴스 멤버는 static 키워드 없이 선언합니다.
  • 인스턴스 필드는 각 객체가 개별적으로 가지는 데이터를 저장하는 데 사용됩니다. 각 객체는 서로 다른 인스턴스 필드 값을 가질 수 있습니다.
  • 인스턴스 메소드는 객체의 상태를 변경하거나, 객체의 상태에 따라 동작하는 기능을 제공하는 데 사용됩니다. 인스턴스 메소드는 객체를 생성한 후에 객체 이름을 통해 호출해야 합니다.
  • 인스턴스 멤버는 객체 이름으로 접근합니다. (예: objectName.InstanceField, objectName.InstanceMethod())

예시 (정적 필드):

public class Counter
{
    // 정적 필드: 모든 Counter 객체가 공유하는 count 값
    public static int count = 0;

    public Counter()
    {
        count++; // 객체 생성 시 정적 필드 count 증가
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        Console.WriteLine($"Counter 객체 생성 전 count: {Counter.count}"); // 0

        Counter c1 = new Counter();
        Console.WriteLine($"c1 객체 생성 후 count: {Counter.count}"); // 1

        Counter c2 = new Counter();
        Console.WriteLine($"c2 객체 생성 후 count: {Counter.count}"); // 2

        Counter c3 = new Counter();
        Console.WriteLine($"c3 객체 생성 후 count: {Counter.count}"); // 3

        Console.WriteLine($"총 Counter 객체 수: {Counter.count}"); // 3
    }
}

예시 (정적 메소드):

public class Calculator
{
    // 정적 메소드: 두 숫자를 더하는 기능
    public static int Add(int a, int b)
    {
        return a + b;
    }

    // 인스턴스 메소드: 현재 객체의 값을 특정 값만큼 증가시키는 기능
    public int Increment(int value, int increment)
    {
        return value + increment;
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        // 정적 메소드는 클래스 이름으로 직접 호출
        int sum = Calculator.Add(5, 3);
        Console.WriteLine($"5 + 3 = {sum}"); // 8

        // 인스턴스 메소드는 객체를 생성한 후 호출
        Calculator calc = new Calculator();
        int result = calc.Increment(10, 5);
        Console.WriteLine($"10 + 5 = {result}"); // 15
    }
}

객체 복사하기: 얕은 복사와 깊은 복사

객체를 복사할 때, 복사 방식에 따라 얕은 복사 (Shallow Copy) 와 깊은 복사 (Deep Copy) 두 가지 방식이 있습니다.

얕은 복사 (Shallow Copy) 는 객체의 필드 값을 값 형식 (Value Type) 은 값 그대로 복사하고, 참조 형식 (Reference Type) 은 참조만 복사하는 방식입니다. 즉, 참조 형식 필드는 원본 객체와 복사된 객체가 동일한 객체를 참조하게 됩니다. 따라서, 복사된 객체의 참조 형식 필드를 변경하면 원본 객체의 필드도 함께 변경되는 side effect가 발생할 수 있습니다.

깊은 복사 (Deep Copy) 는 객체의 모든 필드 값을 새로운 객체에 복사하는 방식입니다. 값 형식은 값 그대로 복사하고, 참조 형식은 참조하는 객체까지 재귀적으로 복사하여 새로운 객체를 생성합니다. 깊은 복사를 통해 원본 객체와 복사된 객체는 완전히 독립적인 객체가 되며, 한 객체의 필드를 변경해도 다른 객체에 영향을 미치지 않습니다.

복사 방식에 따른 특징 비교:

특징 얕은 복사 (Shallow Copy) 깊은 복사 (Deep Copy)
값 형식 복사 값 복사 값 복사
참조 형식 복사 참조 복사 새로운 객체 복사
메모리 사용량 적음 많음
성능 빠름 느림
원본/복사본 독립성 낮음 높음

C# 에서 객체 복사 방법:

  • 얕은 복사: MemberwiseClone() 메소드를 사용하거나, 단순 대입 연산자 (=) 를 사용합니다.
  • 깊은 복사:
    • 수동 복사: 객체의 모든 필드를 일일이 새로운 객체에 복사하는 방법입니다.
    • ICloneable 인터페이스 구현: ICloneable 인터페이스를 구현하고 Clone() 메소드를 재정의하여 깊은 복사 로직을 구현합니다.
    • 직렬화/역직렬화 (Serialization/Deserialization): 객체를 직렬화하여 메모리 스트림에 저장한 후, 다시 역직렬화하여 새로운 객체를 생성하는 방법입니다. 가장 안전하고 일반적인 깊은 복사 방법입니다.

예시 (얕은 복사):

public class Address
{
    public string City;

    public Address(string city)
    {
        City = city;
    }
}

public class Person
{
    public string Name;
    public Address HomeAddress; // 참조 형식 필드

    public Person(string name, Address address)
    {
        Name = name;
        HomeAddress = address;
    }

    public Person ShallowCopy()
    {
        return (Person)this.MemberwiseClone(); // 얕은 복사
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        Address address = new Address("서울");
        Person person1 = new Person("홍길동", address);
        Person person2 = person1.ShallowCopy(); // 얕은 복사

        Console.WriteLine($"person1 주소: {person1.HomeAddress.City}"); // 서울
        Console.WriteLine($"person2 주소: {person2.HomeAddress.City}"); // 서울

        person2.HomeAddress.City = "부산"; // person2의 주소 변경

        Console.WriteLine($"person1 주소 (변경 후): {person1.HomeAddress.City}"); // 부산 (person1 주소도 변경됨 - 얕은 복사)
        Console.WriteLine($"person2 주소 (변경 후): {person2.HomeAddress.City}"); // 부산
    }
}

예시 (깊은 복사 - 수동 복사):

public class Address
{
    public string City;

    public Address(string city)
    {
        City = city;
    }

    public Address DeepCopy()
    {
        return new Address(this.City); // 새로운 Address 객체 생성 (깊은 복사)
    }
}

public class Person
{
    public string Name;
    public Address HomeAddress;

    public Person(string name, Address address)
    {
        Name = name;
        HomeAddress = address;
    }

    public Person DeepCopy()
    {
        Address newAddress = HomeAddress.DeepCopy(); // Address 객체 깊은 복사
        return new Person(this.Name, newAddress); // 새로운 Person 객체 생성
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        Address address = new Address("서울");
        Person person1 = new Person("홍길동", address);
        Person person2 = person1.DeepCopy(); // 깊은 복사

        Console.WriteLine($"person1 주소: {person1.HomeAddress.City}"); // 서울
        Console.WriteLine($"person2 주소: {person2.HomeAddress.City}"); // 서울

        person2.HomeAddress.City = "부산"; // person2의 주소 변경

        Console.WriteLine($"person1 주소 (변경 후): {person1.HomeAddress.City}"); // 서울 (person1 주소는 변경되지 않음 - 깊은 복사)
        Console.WriteLine($"person2 주소 (변경 후): {person2.HomeAddress.City}"); // 부산
    }
}

this 키워드

this 키워드는 현재 객체 자신을 참조하는 키워드입니다. this 키워드는 주로 다음과 같은 상황에서 사용됩니다.

  1. 인스턴스 멤버 접근: 클래스 내부에서 인스턴스 필드나 인스턴스 메소드에 접근할 때 this 키워드를 사용하여 명시적으로 현재 객체의 멤버임을 나타낼 수 있습니다. 필드 이름과 매개변수 이름이 같은 경우, this 키워드를 사용하여 필드를 구분할 수 있습니다.
  2. 생성자 체이닝 (Constructor Chaining): 클래스 내에서 다른 생성자를 호출할 때 this 키워드를 사용합니다. 생성자 체이닝을 통해 코드 중복을 줄이고, 객체 초기화 로직을 효율적으로 관리할 수 있습니다.
  3. 메소드 체이닝 (Method Chaining): 메소드에서 this 키워드를 반환하여 메소드 호출을 연쇄적으로 연결할 수 있습니다. 메소드 체이닝을 통해 코드를 간결하고 가독성 좋게 만들 수 있습니다.

예시 (인스턴스 멤버 접근):

public class Rectangle
{
    private int width;
    private int height;

    public Rectangle(int width, int height)
    {
        // 매개변수 이름과 필드 이름이 같은 경우, this 키워드로 필드를 구분
        this.width = width;
        this.height = height;
    }

    public int GetArea()
    {
        return this.width * this.height; // this 키워드로 현재 객체의 필드에 접근
    }

    public void PrintInfo()
    {
        Console.WriteLine($"가로: {this.width}, 세로: {this.height}, 면적: {this.GetArea()}");
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        Rectangle rect = new Rectangle(10, 5);
        rect.PrintInfo(); // 가로: 10, 세로: 5, 면적: 50
    }
}

예시 (생성자 체이닝):

public class Student
{
    public string Name;
    public int Age;
    public string Major;

    // 기본 생성자
    public Student() : this("이름 없음", 0, "미정") // 매개변수가 3개인 생성자 호출 (생성자 체이닝)
    {
        Console.WriteLine("기본 생성자 호출됨");
    }

    // 매개변수가 2개인 생성자
    public Student(string name, int age) : this(name, age, "미정") // 매개변수가 3개인 생성자 호출 (생성자 체이닝)
    {
        Console.WriteLine("매개변수 2개 생성자 호출됨");
    }

    // 매개변수가 3개인 생성자 (실제 초기화 로직 담당)
    public Student(string name, int age, string major)
    {
        Name = name;
        Age = age;
        Major = major;
        Console.WriteLine("매개변수 3개 생성자 호출됨");
    }

    public void DisplayInfo()
    {
        Console.WriteLine($"이름: {Name}, 나이: {Age}, 전공: {Major}");
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        Student student1 = new Student(); // 기본 생성자 호출
        student1.DisplayInfo(); // 이름: 이름 없음, 나이: 0, 전공: 미정

        Student student2 = new Student("김철수", 20); // 매개변수 2개 생성자 호출
        student2.DisplayInfo(); // 이름: 김철수, 나이: 20, 전공: 미정

        Student student3 = new Student("박영희", 22, "컴퓨터공학"); // 매개변수 3개 생성자 호출
        student3.DisplayInfo(); // 이름: 박영희, 나이: 22, 전공: 컴퓨터공학
    }
}

예시 (메소드 체이닝):

public class StringBuilder
{
    private string text = "";

    public StringBuilder Append(string str)
    {
        this.text += str;
        return this; // 현재 객체 자신을 반환 (메소드 체이닝 가능)
    }

    public StringBuilder AppendLine(string str)
    {
        this.text += str + "\n";
        return this; // 현재 객체 자신을 반환 (메소드 체이닝 가능)
    }

    public override string ToString()
    {
        return this.text;
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        StringBuilder sb = new StringBuilder();
        sb.Append("Hello").AppendLine("World").Append("C#"); // 메소드 체이닝
        Console.WriteLine(sb.ToString());
        // 출력 결과:
        // Hello
        // World
        // C#
    }
}

접근 한정자로 공개 수준 결정하기

접근 한정자 (Access Modifier) 는 클래스 멤버 (필드, 속성, 메소드 등) 의 접근 수준 (Visibility) 을 제어하는 키워드입니다. 접근 한정자를 사용하여 클래스 멤버를 외부로부터 숨기거나, 특정 범위에서만 접근 가능하도록 제한할 수 있습니다. 접근 한정자를 통해 캡슐화 와 정보 은닉 (Information Hiding) 을 구현할 수 있습니다.

C# 에서 제공하는 주요 접근 한정자는 다음과 같습니다.

  • public: 어디서든 접근 가능합니다. 공개적으로 접근 가능한 멤버를 선언할 때 사용합니다.
  • private: 클래스 내부에서만 접근 가능합니다. 클래스 외부에서는 접근할 수 없습니다. 클래스 내부에서만 사용되는 멤버를 선언할 때 사용합니다. 캡슐화를 구현하는 데 핵심적인 역할을 합니다.
  • protected: 클래스 내부와 파생 클래스 (상속받은 클래스) 에서만 접근 가능합니다. 클래스 외부에서는 접근할 수 없습니다. 상속 관계에서 파생 클래스에게만 접근을 허용하는 멤버를 선언할 때 사용합니다.
  • internal: 같은 어셈블리 (Assembly) 내부에서만 접근 가능합니다. 다른 어셈블리에서는 접근할 수 없습니다. 어셈블리 내부에서만 공유되는 멤버를 선언할 때 사용합니다.
  • protected internal: 같은 어셈블리 내부 또는 파생 클래스에서 접근 가능합니다. protectedinternal 의 조합입니다.
  • private protected: 클래스 내부 또는 같은 어셈블리 내의 파생 클래스에서 접근 가능합니다. C# 7.2 버전부터 추가된 접근 한정자입니다.

접근 수준 (높음 -> 낮음):

public > protected internal > internal > protected > private protected > private

클래스와 클래스 멤버의 접근 한정자:

  • 클래스 자체: public 또는 internal 접근 한정자를 사용할 수 있습니다. public 클래스는 어디서든 접근 가능하고, internal 클래스는 같은 어셈블리 내부에서만 접근 가능합니다. 클래스 선언 시 접근 한정자를 명시하지 않으면 기본적으로 internal 접근 수준을 가집니다.
  • 클래스 멤버: public, private, protected, internal, protected internal, private protected 접근 한정자를 모두 사용할 수 있습니다. 클래스 멤버 선언 시 접근 한정자를 명시하지 않으면 기본적으로 private 접근 수준을 가집니다.

예시 (접근 한정자):

// public 클래스: 어디서든 접근 가능
public class MyClass
{
    // public 멤버: 어디서든 접근 가능
    public string PublicField = "public 필드";

    // private 멤버: MyClass 내부에서만 접근 가능
    private string PrivateField = "private 필드";

    // protected 멤버: MyClass 내부 및 파생 클래스에서 접근 가능
    protected string ProtectedField = "protected 필드";

    // internal 멤버: 같은 어셈블리 내부에서 접근 가능
    internal string InternalField = "internal 필드";

    // protected internal 멤버: 같은 어셈블리 내부 또는 파생 클래스에서 접근 가능
    protected internal string ProtectedInternalField = "protected internal 필드";

    // private protected 멤버: MyClass 내부 또는 같은 어셈블리 내의 파생 클래스에서 접근 가능
    private protected string PrivateProtectedField = "private protected 필드";

    public void AccessMembers()
    {
        Console.WriteLine(PublicField);      // 접근 가능
        Console.WriteLine(PrivateField);     // 접근 가능 (같은 클래스 내부)
        Console.WriteLine(ProtectedField);   // 접근 가능 (같은 클래스 내부)
        Console.WriteLine(InternalField);    // 접근 가능 (같은 클래스 내부)
        Console.WriteLine(ProtectedInternalField); // 접근 가능 (같은 클래스 내부)
        Console.WriteLine(PrivateProtectedField); // 접근 가능 (같은 클래스 내부)
    }
}

// 다른 어셈블리에 있는 클래스 (internal 접근 테스트용)
// internal class AnotherClass {}

public class DerivedClass : MyClass
{
    public void AccessMembersFromDerived()
    {
        Console.WriteLine(PublicField);      // 접근 가능 (public)
        // Console.WriteLine(PrivateField);     // 접근 불가능 (private - 다른 클래스)
        Console.WriteLine(ProtectedField);   // 접근 가능 (protected - 파생 클래스)
        Console.WriteLine(InternalField);    // 접근 가능 (internal - 같은 어셈블리)
        Console.WriteLine(ProtectedInternalField); // 접근 가능 (protected internal - 파생 클래스 및 같은 어셈블리)
        Console.WriteLine(PrivateProtectedField); // 접근 가능 (private protected - 같은 어셈블리 내의 파생 클래스)
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        MyClass myObject = new MyClass();
        myObject.AccessMembers(); // MyClass 내부 멤버 접근 확인

        Console.WriteLine(myObject.PublicField);      // 접근 가능 (public)
        // Console.WriteLine(myObject.PrivateField);     // 접근 불가능 (private - 클래스 외부)
        // Console.WriteLine(myObject.ProtectedField);   // 접근 불가능 (protected - 클래스 외부, 파생 클래스에서만 가능)
        Console.WriteLine(myObject.InternalField);    // 접근 가능 (internal - 같은 어셈블리)
        Console.WriteLine(myObject.ProtectedInternalField); // 접근 가능 (protected internal - 같은 어셈블리)
        // Console.WriteLine(myObject.PrivateProtectedField); // 접근 불가능 (private protected - 클래스 외부, 파생 클래스에서만 가능)

        DerivedClass derivedObject = new DerivedClass();
        derivedObject.AccessMembersFromDerived(); // 파생 클래스에서 멤버 접근 확인
    }
}

상속으로 코드 재활용하기

상속(Inheritance)은 객체지향 프로그래밍의 핵심적인 기능 중 하나로, 기존에 정의된 클래스(기반 클래스 또는 부모 클래스)의 속성(필드)과 행위(메소드)를 새로운 클래스(파생 클래스 또는 자식 클래스)가 물려받아 사용할 수 있도록 하는 메커니즘입니다. 상속을 통해 코드를 재활용하고, 중복을 줄이며, 클래스 간의 계층 구조를 형성하여 프로그램의 유지보수성과 확장성을 크게 향상시킬 수 있습니다.

기반 클래스와 파생 클래스 사이의 형식 변환

C#에서는 기반 클래스와 파생 클래스 간에 형식 변환(Type Conversion)이 가능합니다. 이는 객체지향 프로그래밍의 중요한 특징 중 하나이며, 코드의 유연성을 높여줍니다. 형식 변환은 크게 업캐스팅(Upcasting)과 다운캐스팅(Downcasting)으로 나뉩니다.

  • 업캐스팅(Upcasting): 파생 클래스 객체를 기반 클래스 타입으로 변환하는 것을 의미, 업캐스팅은 항상 안전하게 이루어지며, 암시적(Implicit)으로 수행, 파생 클래스는 기반 클래스의 모든 멤버를 포함하므로, 기반 클래스 타입으로 참조해도 정보 손실이 발생하지 않음

  • 다운캐스팅(Downcasting): 기반 클래스 객체를 파생 클래스 타입으로 변환하는 것을 의미, 다운캐스팅은 불안정할 수 있으며, 명시적(Explicit)으로 수행, 기반 클래스 타입의 변수가 실제로 파생 클래스의 객체를 참조하고 있는 경우에만 다운캐스팅이 성공하며, 그렇지 않으면 런타임 예외(InvalidCastException)가 발생, 다운캐스팅은 캐스트 연산자 () 또는 as 키워드를 사용하여 수행할 수 있음

public class Animal
{
    public string Name { get; set; }

    public Animal(string name)
    {
        Name = name;
    }

    public virtual void MakeSound()
    {
        Console.WriteLine($"{Name}이(가) 소리를 냅니다.");
    }
}

public class Dog : Animal
{
    public string Breed { get; set; }

    public Dog(string name, string breed) : base(name)
    {
        Breed = breed;
    }

    public void Bark()
    {
        Console.WriteLine($"{Name}이(가) 멍멍 짖습니다.");
    }

    public override void MakeSound()
    {
        Console.WriteLine($"{Name} ({Breed})이(가) 왈왈 짖습니다.");
    }
}

// 업캐스팅 (암시적)
Dog myDog = new Dog("해피", "푸들");
Animal myAnimal = myDog; // Dog 객체를 Animal 타입으로 참조

myAnimal.MakeSound();   // Dog 클래스에서 오버라이딩된 메소드 호출 (다형성)
// myAnimal.Bark();      // 컴파일 에러! Animal 타입에는 Bark() 메소드가 없음

// 다운캐스팅 (명시적 - 캐스트 연산자 사용)
if (myAnimal is Dog) // 먼저 타입 확인
{
    Dog anotherDog = (Dog)myAnimal;
    anotherDog.Bark(); // Dog 클래스의 메소드 호출 가능
}

Animal anotherAnimal = new Animal("그냥 동물");
// Dog yetAnotherDog = (Dog)anotherAnimal; 
// 런타임 에러 (InvalidCastException) 발생!

// 다운캐스팅 (as 키워드 사용)
Animal yetAnotherAnimal = new Dog("바둑이", "믹스견");
Dog someDog = yetAnotherAnimal as Dog;

if (someDog != null)
{
    someDog.Bark();
}
else
{
    Console.WriteLine("다운캐스팅 실패: yetAnotherAnimal은 Dog 객체가 아닙니다.");
}

Animal justAnimal = new Animal("또 다른 동물");
Dog noDog = justAnimal as Dog;

if (noDog != null)
{
    noDog.Bark();
}
else
{
    Console.WriteLine("다운캐스팅 실패: justAnimal은 Dog 객체가 아닙니다."); // 출력
}

오버라이딩과 다형성

오버라이딩(Overriding)은 파생 클래스에서 기반 클래스의 virtual 로 선언된 메소드를 재정의하는 것을 의미합니다. 이를 통해 파생 클래스는 기반 클래스의 메소드와 동일한 시그니처(이름, 매개변수, 반환 형식)를 가지지만, 자신만의 특화된 동작을 구현할 수 있습니다.

다형성 (Polymorphism) 은 “여러 형태를 가질 수 있음” 이라는 의미로, 객체지향 프로그래밍의 핵심 원리 중 하나입니다. 오버라이딩은 다형성을 구현하는 중요한 메커니즘입니다. 다형성을 통해 기반 클래스 타입의 변수로 여러 파생 클래스의 객체를 참조하고, 동일한 메소드 호출에 대해 각 객체의 실제 타입에 따라 서로 다른 동작을 수행하도록 할 수 있습니다. 이를 런타임 다형성 (Runtime Polymorphism) 또는 동적 바인딩 (Dynamic Binding) 이라고 합니다.

public class Animal
{
    public string Name { get; set; }

    public Animal(string name)
    {
        Name = name;
    }

    public virtual void MakeSound() // virtual 키워드로 오버라이딩 가능하도록 선언
    {
        Console.WriteLine($"{Name}이(가) 소리를 냅니다.");
    }
}

public class Dog : Animal
{
    public Dog(string name) : base(name) { }

    public override void MakeSound() // override 키워드로 기반 클래스의 메소드 오버라이딩
    {
        Console.WriteLine($"{Name}이(가) 멍멍 짖습니다.");
    }
}

public class Cat : Animal
{
    public Cat(string name) : base(name) { }

    public override void MakeSound() // override 키워드로 기반 클래스의 메소드 오버라이딩
    {
        Console.WriteLine($"{Name}이(가) 야옹 울음소리를 냅니다.");
    }
}

var animals = new Animal[3];
animals[0] = new Animal("동물");
animals[1] = new Dog("해피");   // 업캐스팅
animals[2] = new Cat("나비");   // 업캐스팅

foreach (Animal animal in animals)
{
    animal.MakeSound();
}

메소드 숨기기

메소드 숨기기(Method Hiding) 는 파생 클래스에서 기반 클래스와 동일한 시그니처(이름, 매개변수, 반환 형식)를 가진 메소드를 new 키워드를 사용하여 선언하는 것을 의미합니다. 메소드 숨기기는 오버라이딩과 유사하게 파생 클래스에서 기반 클래스의 메소드를 재정의하는 효과를 가지지만, 다형성은 적용되지 않습니다.

오버라이딩과의 차이점은 아래와 같습니다.

특징 오버라이딩 (Overriding) 메소드 숨기기 (Method Hiding)
키워드 virtual, override new
다형성 적용 여부 적용 (런타임 다형성) 미적용
기반 클래스 타입 참조 시 메소드 호출 파생 클래스의 오버라이딩된 메소드 기반 클래스의 메소드
public class BaseClass
{
    public void PrintMessage()
    {
        Console.WriteLine("BaseClass 메시지");
    }
}

public class DerivedClass : BaseClass
{
    public new void PrintMessage()
    {
        Console.WriteLine("DerivedClass 메시지");
    }
}

BaseClass baseObj = new BaseClass();
baseObj.PrintMessage(); // 출력: BaseClass 메시지

DerivedClass derivedObj = new DerivedClass();
derivedObj.PrintMessage(); // 출력: DerivedClass 메시지

BaseClass polyObj = new DerivedClass(); // 업캐스팅
polyObj.PrintMessage(); // 출력: BaseClass 메시지 (오버라이딩과 달리 기반 클래스의 메소드가 호출됨)

오버라이딩 봉인하기

오버라이딩 봉인 (Sealing Overriding) 은 파생 클래스에서 override 키워드를 사용하여 오버라이딩한 메소드를 더 이상 상속 계층에서 재정의할 수 없도록 봉인하는 기능입니다. sealed 키워드를 override 키워드와 함께 사용하여 메소드를 봉인합니다.

  • 클래스 설계의 최종 확정: 특정 메소드의 동작 방식을 더 이상 변경하지 못하도록 하여 클래스 구조를 안정화
  • 성능 향상 (미미): 가상 메소드 호출 시 발생하는 약간의 런타임 오버헤드를 줄일 수 있음(최적화에 도움이 될 수 있지만, 일반적으로 큰 성능 차이는 없음).
public class Animal
{
    public virtual void MakeSound()
    {
        Console.WriteLine("동물이 소리를 냅니다.");
    }
}

public class Dog : Animal
{
    public sealed override void MakeSound() // sealed 키워드를 사용하여 오버라이딩 봉인
    {
        Console.WriteLine("강아지가 멍멍 짖습니다.");
    }
}

public class Poodle : Dog
{
    // public override void MakeSound() 
    // 컴파일 에러 발생! Dog 클래스의 MakeSound()는 봉인되었으므로 오버라이딩할 수 없습니다.
    public void DoTrick()
    {
        Console.WriteLine("푸들이 재주를 부립니다.");
    }
}

Dog myDog = new Dog();
myDog.MakeSound(); // 출력: 강아지가 멍멍 짖습니다.

Poodle myPoodle = new Poodle();
myPoodle.MakeSound(); // 출력: 강아지가 멍멍 짖습니다. (Dog 클래스에서 봉인된 메소드 호출)
myPoodle.DoTrick();   // 출력: 푸들이 재주를 부립니다.

읽기 전용 필드

읽기 전용 필드(Read-only Field)는 readonly 키워드를 사용하여 선언하는 필드입니다. 읽기 전용 필드는 선언 시 또는 클래스의 생성자 (Constructor) 내에서만 값을 할당할 수 있으며, 그 이후에는 값을 변경할 수 없습니다.

  • 불변성 (Immutability): 객체가 생성된 후에는 필드의 값을 변경할 수 없으므로, 객체의 상태를 안전하게 유지할 수 있음
  • 안전성: 의도치 않은 값 변경을 방지하여 프로그램의 안정성을 높음
  • 코드 명확성: 필드가 읽기 전용임을 명시적으로 나타내어 코드의 의도를 더 잘 전달함

선언 및 초기화 관련은 아래와 같습니다.

  • 선언 시 초기화: 필드를 선언할 때 바로 값을 할당
  • 생성자 내에서 초기화: 클래스의 생성자 내에서 값을 할당, 여러 개의 생성자가 있는 경우, 모든 생성자에서 읽기 전용 필드를 초기화해야 함
  • 정적 읽기 전용 필드(static readonly): 클래스 로드 시 한 번만 초기화할 수 있으며, 정적 생성자에서도 초기화할 수 있음
public class Circle
{
    public readonly double Radius; // 읽기 전용 필드

    public static readonly double PI = 3.141592; // 정적 읽기 전용 필드

    // 생성자에서 읽기 전용 필드 초기화
    public Circle(double radius)
    {
        Radius = radius;
        // PI = 3.14; // 컴파일 에러! 정적 읽기 전용 필드는 정적 생성자 또는 선언 시에만 할당 가능
    }

    // 정적 생성자 (정적 읽기 전용 필드 초기화에 사용 가능)
    // static Circle()
    // {
    //     PI = 3.141592;
    // }

    public double GetArea()
    {
        return PI * Radius * Radius;
    }

    public void ChangeRadius(double newRadius)
    {
        // Radius = newRadius; 
        // 컴파일 에러! 읽기 전용 필드는 생성자 외부에서 값 변경 불가능
    }
}

Circle myCircle = new Circle(5.0);
Console.WriteLine($"원의 반지름: {myCircle.Radius}"); // 출력: 원의 반지름: 5
Console.WriteLine($"원의 넓이: {myCircle.GetArea()}"); // 출력: 원의 넓이: 78.5398

// myCircle.Radius = 10.0; // 컴파일 에러! 읽기 전용 필드는 값 변경 불가

Console.WriteLine($"PI 값: {Circle.PI}"); // 출력: PI 값: 3.141592
// Circle.PI = 3.14; // 컴파일 에러! 정적 읽기 전용 필드는 값 변경 불가

중첩 클래스

중첩 클래스 (Nested Class) 는 클래스 내부에 정의된 또 다른 클래스를 의미합니다. 중첩 클래스는 외부 클래스(Outer Class)와 밀접한 관계를 가지는 경우, 코드의 캡슐화 와 논리적 그룹화 를 위해 사용됩니다. 중첩 클래스는 외부 클래스의 멤버(필드, 속성, 메소드 등)에 접근할 수 있으며, 반대로 외부 클래스도 중첩 클래스의 멤버에 접근할 수 있습니다(접근 한정자에 따라 제한될 수 있음).

  • 정적 중첩 클래스 (Static Nested Class): static 키워드를 사용하여 선언된 중첩 클래스, 정적 중첩 클래스는 외부 클래스의 인스턴스 멤버에는 접근할 수 없지만, 정적 멤버에는 접근할 수 있으며, 정적 중첩 클래스는 외부 클래스의 인스턴스와 독립적으로 존재할 수 있음

  • 인스턴스 중첩 클래스 (Instance Nested Class) 또는 내부 클래스 (Inner Class): static 키워드 없이 선언된 중첩 클래스, 인스턴스 중첩 클래스는 외부 클래스의 모든 멤버(정적 및 인스턴스 멤버)에 접근할 수 있으며, 인스턴스 중첩 클래스는 항상 외부 클래스의 특정 인스턴스와 연결되어 생성

public class Computer
{
    public string ModelName { get; set; }

    // 인스턴스 중첩 클래스
    public class Monitor
    {
        private readonly Computer _outerComputer;
        public string Resolution { get; set; }

        public Monitor(Computer outerComputer)
        {
            _outerComputer = outerComputer;
        }

        public void DisplayModelName()
        {
            Console.WriteLine($"컴퓨터 모델명: {_outerComputer.ModelName}"); // 외부 클래스의 인스턴스 멤버에 접근
        }
    }

    // 정적 중첩 클래스
    public static class Keyboard
    {
        public static string Layout { get; set; } = "US";

        public static void PrintLayout()
        {
            Console.WriteLine($"키보드 레이아웃: {Layout}");
        }
    }

    public Computer(string modelName)
    {
        ModelName = modelName;
    }

    public void Start()
    {
        // 외부 클래스의 인스턴스를 내부 클래스에 전달
        Monitor monitor = new Monitor(this); 
        monitor.Resolution = "1920x1080";
        monitor.DisplayModelName();

        // 정적 중첩 클래스는 외부 클래스의 인스턴스 없이 접근 가능
        Keyboard.PrintLayout(); 
    }
}

Computer myComputer = new Computer("MyPC");
myComputer.Start();

// 외부에서 중첩 클래스 접근
Computer.Monitor myMonitor = new Computer.Monitor(myComputer);
myMonitor.Resolution = "2560x1440";
myMonitor.DisplayModelName();

Computer.Keyboard.Layout = "KR";
Computer.Keyboard.PrintLayout();

분할 클래스

분할 클래스(Partial Class)는 partial 키워드를 사용하여 클래스의 정의를 여러 개의 소스 파일로 분할할 수 있도록 하는 기능입니다. 컴파일 시 모든 분할 클래스의 정의가 하나의 클래스로 합쳐집니다.

  • 자동 생성 코드와의 분리: Visual Studio와 같은 개발 도구에서 자동으로 생성되는 코드(예: WinForms 디자이너 코드, Entity Framework 모델 코드)와 사용자가 직접 작성하는 코드를 분리하여 관리하기 용이
  • 대규모 클래스 관리: 하나의 클래스가 너무 커져서 관리하기 어려울 때, 기능을 기준으로 여러 파일로 나누어 개발할 수 있음
  • 팀 협업: 여러 개발자가 하나의 클래스를 동시에 작업할 때, 각자 다른 파일에서 클래스의 일부분을 구현할 수 있어 충돌을 줄일 수 있음
// File1.cs:
public partial class MyClass
{
    public string Name { get; set; }

    public MyClass(string name)
    {
        Name = name;
    }

    public void Greet()
    {
        Console.WriteLine($"안녕하세요, 제 이름은 {Name}입니다.");
    }
}

// File2.cs:
public partial class MyClass
{
    public int Age { get; set; }

    public void IntroduceAge()
    {
        Console.WriteLine($"제 나이는 {Age}살입니다.");
    }
}

// Program.cs:
public class Example
{
    public static void Main(string[] args)
    {
        MyClass obj = new MyClass("홍길동");
        obj.Age = 30;
        obj.Greet();
        obj.IntroduceAge();
    }
}

확장 메소드

확장 메소드(Extension Method)는 기존 클래스 또는 인터페이스의 기능을 수정하지 않고 새로운 메소드를 추가할 수 있도록 하는 C#의 특별한 기능입니다. 확장 메소드를 사용하면 소스 코드에 직접 접근할 수 없거나 수정할 수 없는 타입에 유용한 기능을 추가할 수 있습니다.

  • 확장 메소드는 static 클래스 내부에 static 메소드로 정의
  • 확장 대상이 되는 타입의 매개변수 앞에는 this 키워드를 사용
  • 확장 메소드는 마치 해당 타입의 인스턴스 메소드처럼 호출할 수 있음
using System;

// 확장 메소드를 정의하는 static 클래스
public static class StringExtensions
{
    // string 타입에 새로운 메소드 IsPalindrome 추가
    public static bool IsPalindrome(this string str)
    {
        if (str == null) return false;
        str = str.ToLower();
        int left = 0;
        int right = str.Length - 1;
        while (left < right)
        {
            if (str[left] != str[right])
            {
                return false;
            }
            left++;
            right--;
        }
        return true;
    }

    // string 타입에 새로운 메소드 WordCount 추가
    public static int WordCount(this string str)
    {
        if (string.IsNullOrEmpty(str)) return 0;
        return str.Split(new char{ ' ', '.', '?' }, StringSplitOptions.RemoveEmptyEntries).Length;
    }
}

public class Example
{
    public static void Main(stringargs)
    {
        string text1 = "racecar";
        bool isPalindrome1 = text1.IsPalindrome(); // 확장 메소드를 인스턴스 메소드처럼 호출
        Console.WriteLine($"'{text1}'은(는) 회문인가요? {isPalindrome1}"); // 출력: 'racecar'은(는) 회문인가요? True

        string text2 = "Hello World!";
        int wordCount = text2.WordCount(); // 확장 메소드를 인스턴스 메소드처럼 호출
        Console.WriteLine($"'{text2}'의 단어 수: {wordCount}"); // 출력: 'Hello World!'의 단어 수: 2

        string text3 = null;
        bool isPalindrome3 = text3.IsPalindrome();
        Console.WriteLine($"'{text3}'은(는) 회문인가요? {isPalindrome3}"); // 출력: ''은(는) 회문인가요? False
    }
}

구조체

구조체(Struct)는 클래스와 유사하게 데이터 멤버(필드)와 함수 멤버(메소드, 속성 등)를 포함할 수 있는 값 형식(Value Type) 입니다. 클래스는 참조 형식(Reference Type) 인 반면, 구조체는 스택에 할당되며 복사될 때 실제 값이 복사됩니다. 클래스와 구조체의 주요 차이점은 아래와 같습니다.

  • 값 형식 vs. 참조 형식: 구조체는 값 형식이며, 클래스는 참조 형식입니다.
  • 상속: 구조체는 클래스를 상속받을 수 없으며, 다른 구조체로부터 상속받을 수도 없습니다. 하지만 인터페이스는 구현할 수 있습니다.
  • 생성자: 구조체는 명시적인 기본 생성자(매개변수 없는 생성자)를 가질 수 없습니다. 필드를 초기화하지 않고 구조체 변수를 선언하면 필드는 기본값으로 설정됩니다.
  • 소멸자 (Finalizer): 구조체는 소멸자를 가질 수 없습니다.
  • null 값: 구조체는 기본적으로 null 값을 가질 수 없습니다. (Nullable 구조체는 가능)

언제 구조체를 사용해야 할까요?

  • 작고 값 위주의 데이터 집합을 표현할 때 (예: 좌표, 색상, 간단한 데이터 전송 객체).
  • 잦은 객체 생성 및 소멸이 발생하는 상황에서 성능 향상을 기대할 때 (힙 할당 비용 절감).
  • 값 복사가 의미상 자연스러울 때.
using System;

public struct Point
{
    public int X { get; set; }
    public int Y { get; set; }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public double DistanceToOrigin()
    {
        return Math.Sqrt(X * X + Y * Y);
    }

    public override string ToString()
    {
        return $"({X}, {Y})";
    }
}

Point p1 = new Point(10, 20);
Console.WriteLine($"점 p1: {p1}"); // 출력: 점 p1: (10, 20)
Console.WriteLine($"원점으로부터의 거리: {p1.DistanceToOrigin()}"); // 출력: 원점으로부터의 거리: 22.3606797749979

Point p2 = p1; // 값 복사
p2.X = 5;
Console.WriteLine($"점 p1 (수정 후): {p1}"); // 출력: 점 p1 (수정 후): (10, 20) (p1은 변경되지 않음)
Console.WriteLine($"점 p2: {p2}"); // 출력: 점 p2: (5, 20)

Point p3; // 초기화 없이 선언 (각 필드는 기본값으로 설정됨)
p3.X = 100;
p3.Y = 200;
Console.WriteLine($"점 p3: {p3}"); // 출력: 점 p3: (100, 200)

튜플

튜플(Tuple)은 여러 개의 필드를 갖는 단순한 데이터 구조로, 서로 다른 타입의 값들을 하나의 경량 객체로 묶을 수 있게 해줍니다. C# 7.0부터는 튜플에 이름을 지정할 수 있어 코드의 가독성과 유지보수성이 향상되었습니다.

  • 튜플은 값 형식
  • 튜플은 불변(Immutable), 생성 후에는 요소의 값을 변경할 수 없음(C# 9.0부터는 record struct를 통해 변경 가능한 값 형식 레코드를 사용할 수 있음).
  • 익명 타입으로 간단하게 생성하거나, 명시적으로 타입을 지정하여 생성할 수 있습니다.
  • 튜플 요소에 이름을 지정하여 의미를 명확하게 할 수 있습니다.
using System;

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public static (string Name, int Age) GetPersonInfo()
    {
        return ("김철수", 25); // 이름이 지정된 튜플 반환
    }

    public static Tuple<string, int, string> GetProductInfo()
    {
        return new Tuple<string, int, string>("노트북", 1200000, "전자제품"); // 명시적인 튜플 타입 사용
    }
}

// 튜플 생성 및 값 접근 (이름 지정)
(string Name, int Age) person = Person.GetPersonInfo();
Console.WriteLine($"이름: {person.Name}, 나이: {person.Age}"); // 출력: 이름: 김철수, 나이: 25

var anotherPerson = Person.GetPersonInfo(); // var 키워드로 타입 추론
Console.WriteLine($"이름: {anotherPerson.Name}, 나이: {anotherPerson.Age}"); // 출력: 이름: 김철수, 나이: 25

// 튜플 생성 및 값 접근 (이름 없음)
(string, int, string) product = ("마우스", 30000, "주변기기");
Console.WriteLine($"제품명: {product.Item1}, 가격: {product.Item2}, 분류: {product.Item3}"); // 출력: 제품명: 마우스, 가격: 30000, 분류: 주변기기

// 명시적인 튜플 타입 사용
Tuple<string, int, string> productInfo = Person.GetProductInfo();
Console.WriteLine($"제품명: {productInfo.Item1}, 가격: {productInfo.Item2}, 분류: {productInfo.Item3}"); // 출력: 제품명: 노트북, 가격: 1200000, 분류: 전자제품

// 튜플 분해 (Tuple Deconstruction)
var (productName, productPrice, productCategory) = Person.GetProductInfo();
Console.WriteLine($"제품명: {productName}, 가격: {productPrice}, 분류: {productCategory}"); // 출력: 제품명: 노트북, 가격: 1200000, 분류: 전자제품

이러한 C#의 다양한 클래스 관련 기능들을 이해하고 적절하게 활용하면 더욱 효율적이고 유지보수성이 높은 코드를 작성할 수 있습니다.