Skip to main content

[This is C#] Chapter 7. 클래스

이것이 C#이다 책을 읽고 공부한 노트입니다.




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

  • 객체지향 프로그래밍(Object Oriented Programming; OOP)
    • 문제 상황에 맞는 데이터형(클래스)을 만들어서 프로그래밍하는 방법이다.
    • 클래스가 객체를 만들기 위한 청사진이라면, 객체는 그 실체(Instance)라고 볼 수 있다. 따라서 객체를 인스턴스라고 부르기도 한다.
      • 클래스는 복합 데이터 형식이다.
      • 객체는 속성(데이터), 기능(메소드)으로 이루어져 있다.



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

class Cat
{
    public string Name;
    public string Color;

    public void Meow()
    {
        Console.WriteLine("{0} : 야옹", Name);
    }
}

class MainApp
{
    static void Main(string[] args)
    {
        Cat kitty1 = new Cat(); // Cat 객체를 생성한다.
        kitty1.Name = "키티";
        kitty1.Color = "하얀색";
                
        Cat kitty2;             // null을 가진다. 
    }
}

  • new 연산자와 생성자는 모든 데이터 형식에 사용할 수 있다.
    • int a = new int(); // (O)



생성자와 종료자 #

생성자 #

  • 형식
    • 클래스와 이름이 같고, 반환 형식이 없다.
  • 정의
    • 명시적으로 생성자를 정의하지 않아도 컴파일러에서 생성자를 만들어 준다.
    • 하지만 하나라도 생성자를 직접 정의하면, 기본 생성자를 제공하지 않는다.
    • 오버로딩이 가능하다.
class Cat
{
    public string Name;
    public string Color;

    public Cat()
    {
        Name = "";
        Color = "";
    }

    public Cat(string _Name, string _Color)
    {
        Name = _Name;
        Color = _Color;
    }

    public void Meow()
    {
        Console.WriteLine("{0} : 야옹", Name);
    }
}

종료자 #

  • 형식
    • 클래스 이름 앞에 ~를 붙인다.
    • 생성자와 달리 매개변수도 없고, 한정자도 없다.
  • 정의
    • 오버로딩이 불가능하다.
    • 종료자는 되도록 구현하지 않는 것이 좋다.
    • CLR의 가비지 컬렉터가 객체가 소멸되는 시점을 판단해서 종료자를 호출해준다. 가비지 컬렉터가 우리보다 훨씬 똑똑하게 객체의 소멸을 처리할 수 있다.
    • 종료자를 명시적으로 구현하면 가비지 컬렉터는 클래스의 족보를 타고 올라가서 객체로부터 상속받은 Finalize()메소드를 호출한다. 이렇게 되면 성능 저하를 초래할 확률이 높아진다.
class Cat
{
    public string Name;
    public string Color;

    public Cat()
    {
        Name = "";
        Color = "";
    }

    public Cat(string _Name, string _Color)
    {
        Name = _Name;
        Color = _Color;
    }
    
    ~Cat()
    {
        Console.WriteLine("{0}, 잘가", Name);
    }
    
    public void Meow()
    {
        Console.WriteLine("{0} : 야옹", Name);
    }
}

정적 필드와 메소드 #

  • 정적(static) 필드, 메소드란?
    • 필드나 메소드가 인스턴스가 아닌, 클래스 자체에 소속되도록 하는 것이다.
    • 프로그램 전체에 걸쳐 하나밖에 존재하지 않는다.
    • 인스턴스를 생성하지 않아도 호출이 가능하다.
class MyClass
{
    public static int StaticVal;
    public int Val;

    public static void StaticFunc()
    {
        //...
    }

    public void Func()
    {
        //...
    }
}

class MainApp
{
    static void Main(string[] args)
    {
        // 인스턴스 없이 접근 가능한 static 필드와 메소드
        MyClass.StaticVal = 1;
        MyClass.StaticFunc();

        // 인스턴스에 소속되는 필드와 메소드
        MyClass mine = new MyClass();
        mine.Val = 1;
        mine.Func();
    }
}



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

  • 얕은 복사(Shallow Copy)
    • 클래스는 태생이 참조 형식이기 때문에 다음 코드와 같은 경우, 두 객체 모두 하나의 힙에 있는 데이터를 가리키게 되는 문제가 있다.
class MyClass
{
    public int Val;
}

class MainApp
{
    static void Main(string[] args)
    {
        MyClass a = new MyClass();
        a.Val = 1;

        MyClass copyA = a;
        copyA.Val = 2;

        Console.WriteLine(a.Val); // 2
    }
}

  • 깊은 복사(Deep copy)
    • 우리가 직접 깊은 복사를 수행하는 코드를 만들어서, 다른 힙에 저장되도록 할 수 있겠다.
class MyClass
{
    public int Val;

    public MyClass DeepCopy()
    {
        MyClass newCopy = new MyClass();
        newCopy.Val = Val;
        return newCopy;
    }
}

class MainApp
{
    static void Main(string[] args)
    {
        MyClass a = new MyClass();
        a.Val = 1;

        MyClass copyA = a.DeepCopy();
        copyA.Val = 2;

        Console.WriteLine(a.Val); // 1
    }
}



this 키워드 #

  • this 키워드

    • 객체 내부에서 자기 자신의 필드나 메소드에 접근할 때 사용한다.
  • this() 생성자

    • 자기 자신의 생성자를 가리킨다.
    • 생성자에서만 사용될 수 있다.
class MyClass
{
    private int a, b, c;

    public MyClass()
    {
        this.a = 0; 
    }

    public MyClass(int b) : this()
    {
        this.b = b;
    }

    public MyClass(int b, int c) : this(b)
    {
        this.c = c;
    }
}



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

  • C#에서 제공하는 접근 한정자 6가지
접근 한정자 설명
public 클래스 내부/외부 모든 곳에서 접근할 수 있다.
protected 클래스의 외부에서는 접근할 수 없지만, 파생 클래스에서는 접근이 가능하다.
private 클래스의 내부에서만 접근할 수 있다. 파생 클래스에서도 접근이 불가능하다.
internal 같은 어셈블리에 있는 코드에서만 public으로 접근할 수 있다.
다른 어셈블리에 있는 코드에서는 private과 같은 수준의 접근성을 가진다.
protected internal 같은 어셈블리에 있는 코드에서만 protected으로 접근할 수 있다.
다른 어셈블리에 있는 코드에서는 private과 같은 수준의 접근성을 가진다.
private internal 같은 어셈블리에 있는 클래스에서 생속받은 클래스 내부에서만 접근이 가능하다.

  • 클래스의 멤버의 접근 한정자는 디폴트로 private이다.



상속으로 코드 재활용하기 #

  • 객체를 생성할 때
    • 기반 클래스의 생성자 → 파생 클래스의 생성자
  • 객체가 소멸될 때
    • 파생 클래스의 소멸자 → 기반 클래스의 소멸자

  • 매개변수를 가지는 기반 클래스의 생성자를 호출하는 방법?
    • base 키워드를 사용한다. 이것은 기반 클래스를 가리킨다.
    • base 키워드로 기반 클래스의 필드나 메서드에 접근할 수 있다.
class BaseClass
{
    public int BaseVal;

    public BaseClass(int BaseVal)
    {
        this.BaseVal = BaseVal;
    }

    public void BaseMethod()
    {
        //...
    }
}

class DerivedClass : BaseClass
{
    public int DerivedVal;

    // base()를 통해 기반 클래스의 생성자 호출가능. 
    public DerivedClass(int BaseVal, int DerivedVal) : base(BaseVal)
    {
        this.DerivedVal = DerivedVal;
    }

    public void DerivedMethod()
    {
        // base 키워드를 통해 기반 클래스의 필드나 메서드에 접근 가능. 
        int val = base.BaseVal;
        base.BaseMethod();
    }
}

  • 상속이 불가능하도록 만드려면?
    • sealed 키워드를 사용한다.
sealed class BaseClass
{
    //...
}

class DerivedClass : BaseClass // 컴파일 에러!
{
    //...
}



기반 클래스와 파생 클래스 사이의 형식 변환, 그리고 is와 as #

  • C#에서 형식 변환을 위한 연산자: is, as
    • (Dog)같이 형식을 변환하는 것 보다는 as Dog과 같이 사용하는 것을 권장한다.
    • as참조 형식에 대해서만 사용이 가능하므로, 값 형식의 객체는 기존의 형식 변환 연산자를 사용해야 한다.
연산자 설명
is 객체가 해당 형식으로 변환 가능한지 검사해서 그 결과를 bool값으로 반환한다.
as 객체를 해당 형식으로 변환한다. 만약 실패하면 null을 반환한다.
class Animal
{
}

class Dog : Animal
{
    public void Bark()
    {
    }
}

class Cat : Animal
{
    public void Meow()
    {
    }
}

class MainApp
{
    static void Main(string[] args)
    {
        Animal ani = new Dog();
        
        if (ani is Dog)
        {
            Dog dog = (Dog) ani;
            dog.Bark();
        }

        Cat cat = ani as Cat;
        if (cat != null)
        {
            cat.Meow();
        }
	}
}



오버라이딩과 다형성 #

  • 다형성(Polymorphism)
    • 객체가 여러 형태를 가질 수 있음을 의미한다.
  • 오버라이딩(Overriding)
    • 다형성을 실현하는 방법이다. 기반 클래스의 메소드를 파생 클래스에서 재정의하는 것을 말한다.
    • 기반 클래스의 메소드가 virtual 키워드로 한정되어 있어야 한다. 그리고 파생 클래스의 메소드에서 override 키워드로 한정하여 컴파일러에게 재정의하고 있음을 알린다.
    • private으로 선언한 메소드는 오버라이딩 할 수 없다.
class Animal
{  
    public virtual void MakeSound()
    {
        Console.WriteLine("Animal");
    }
}

class Dog : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("Bark");
    }
}

class Cat : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("Meow");
    }
}

class MainApp
{
    static void Main(string[] args)
    {
        Animal ani = new Dog();
        ani.MakeSound(); // Bark

        ani = new Cat();
        ani.MakeSound(); // Meow
    }
}



메소드 숨기기 #

  • 메소드 숨기기(Method Hiding)
    • CLR에게 기반 클래스에서 구현된 버전의 메소드를 감추고, 파생 클래스에서 구현된 버전만 보여주는 것이다.
    • 파생 클래스의 메소드에 new 키워드를 사용한다.
    • 오버라이딩과 다른 점이라면, 아래 코드처럼 메소드를 단순히 숨기기만 한다는 점이다.
class Animal
{
    public virtual void MakeSound()
    {
        Console.WriteLine("Animal");
    }
}

class Dog : Animal
{
    public new void MakeSound()
    {
        Console.WriteLine("Bark");
    }
}

class MainApp
{
    static void Main(string[] args)
    {
        Animal ani = new Dog();
        ani.MakeSound(); // Animal
	}
}



오버라이딩 봉인하기 #

  • 메소드를 오버라이딩되지 않도록 봉인하기
    • sealed 키워드를 사용한다.
    • 기반 클래스에서 virtual로 선언된 메서드를 상속하는 파생 클래스의 메서드만 가능하다.
class Animal
{
    public virtual void MakeSound()
    {
        Console.WriteLine("Animal");
    }
}

class Dog : Animal
{
    public sealed override void MakeSound() // sealed
    {
        Console.WriteLine("Bark");
    }
}

class Bulldog : Dog
{
    public override void MakeSound() // 컴파일 에러!
    {
    }
}



읽기 전용 필드 #

  • 읽기 전용 필드
    • readonly 키워드를 사용한다.
    • 생성자 안에서 한 번 값을 지정하면, 그 후로는 값을 변경할 수 없다.
    • 클래스나 구조체의 멤버로만 존재할 수 있다.
class MyClass
{  
    private readonly int val;

    public MyClass()
    {
        val = 1; // 생성자에서는 readonly 초기화 가능
    }

    public void MyMethod()
    {
        val = 1; // 컴파일 에러!
    }
}



중첩 클래스 #

  • 중첩 클래스(Nested Class)
    • 클래스 안에 선언되어 있는 클래스이다.
    • 자신이 소속되어 있는 클래스의 멤버에 자유롭게 접근할 수 있다.
class OuterClass
{
    private int val;

    class NestedClass
    {
        public void DoSomething()
        {
            OuterClass outer = new OuterClass();
            outer.val = 1; // private이라도 접근이 가능하다. 
        }
    }
}



분할 클래스 #

  • 분할 클래스(Partial Class)
    • 클래스의 구현이 길어질 경우 여러 파일에 나눠서 구현할 수 있게 한다.
    • partial 키워드를 사용한다.
partial class MyClass
{
    public void Method1() {}
    public void Method2() {}
}

partial class MyClass
{
    public void Method3() {}
    public void Method4() {}
}



확장 메소드 #

  • 확장 메소드(Extension Method)
    • 기반 클래스의 기능을 확장한다.
    • 이것은 기반 클래스를 물려받아서 파생 클래스를 만든 뒤 필드나 메소드를 추가하는 상속과는 다른 것이다.
    • 확장 메소드를 사용하면, string 클래스에 문자열을 뒤집는 기능을 넣을 수도 있고, int형식에 제곱 연산 기능을 넣을 수도 있다.
  • 선언 방법
    • 선언하는 클래스와 메소드는 static한정자로 수식해야 한다.
    • 확장 메소드의 첫번째 매개변수는 반드시 this 키워드와 함께 확장하고자 하는 클래스(형식)의 인스턴스여야 한다.
static class IntegerExtension
{
    public static int Power(this int myInt, int exponent)
    {
        int result = myInt;
        for (int i = 1; i < exponent; i++)
            result = result * myInt;
        return result;
    }
}

class MainApp
{
    static void Main(string[] args)
    {
        int pow = 2.Power(4);

        int a = 2;
        pow = a.Power(4);
    }
}



구조체 #

  • 클래스와 구조체의 차이점
특징 클래스 구조체
형식 참조 형식 값 형식
복사 얕은 복사 깊은 복사
인스턴스 생성 new연산자와 생성자 필요 선언만으로 생성
생성자 제한 없음 매개변수 없는 생성자는 선언 불가능
모든 필드를 초기화하지 않는 생성자는 선언 불가능
상속 가능 불가능
변경불가능 선언 불가능 가능(readonly)
읽기 전용 메소드 불가능 가능(readonly)

  • 변경불가능(Immutable) 구조체
    • readonly 키워드를 사용한다.
    • 해당 구조체의 모든 필드가 readonly로 선언되도록 강제한다.
readonly struct ImmutableStruct
{
    public readonly int ImmutableField; // (O)
    public int MutableField; // 컴파일 에러!

    public ImmutableStruct(int val)
    {
        ImmutableField = val; // 생성자에서만 초기화 가능
    }
}

  • 읽기 전용 메소드
    • readonly 키워드를 사용한다.
    • 해당 메소드가 객체의 상태를 바꿀 수 없게 한다.
    • 구조체에서만 가능하다.
struct MyStruct
{
    public int Value;

    public readonly void TryToChange() // readonly 메소드는 객체의 상태를 바꿀 수 없다
    {
        Value = 1; // 컴파일 에러!
    }
}



튜플 #

  • 튜플(Tuple)
    • 여러 필드를 담을 수 있는 구조체이다.
    • 구조체이므로 값 형식이다.

  • 명명되지 않은 튜플(Unnamed Tuple)
    • 필드의 이름을 지정하지 않은 튜플
  • 명명된 튜플(Named Tuple)
    • 필드의 이름을 지정한 튜플
// 컴파일러가 튜플의 모양을 보고 직접 형식을 결정하도록 var를 이용해서 선언한다. 

// 명명되지 않은 튜플
var unnamedTuple = ("박찬호", 13);
Console.WriteLine($"{unnamedTuple.Item1}, {unnamedTuple.Item2}");

// 명명된 튜플
var namedTuple = (Name: "박찬호", Age: 13);
Console.WriteLine($"{namedTuple.Name}, {namedTuple.Age}");

// 튜플 분해하기
var (name, age) = namedTuple;
Console.WriteLine($"{name}, {age}");

// 튜플 분해 시 특정 필드 무시하기
var (n, _) = namedTuple;
Console.WriteLine($"{n}");

// 튜플 생성과 분해를 한 번에 하기 -> 여러 변수를 단번에 초기화
var (color, value) = ("White", 33);
Console.WriteLine($"{color}, {value}");

  • 튜플의 분해
    • 튜플은 분해자(Deconstructor)를 구현하고 있기 때문에 분해가 가능하다.
    • 분해한 결과를 switch문이나 switch식의 분기 조건에 활용할 수도 있다. 이것을 위치 패턴 매칭(Positional Pattern Matching)이라고 한다.
var alice = (Job: "학생", Age: 30);

var discountRate = alice switch
{
    ("학생", int n) when n < 18 => 0.2,
    ("학생", _)                 => 0.1,
    ("일반", int n) when n < 18 => 0.1,
    ("일반", _)                 => 0.05,
    _                           => 0
};