22. 객체지향 프로그래밍

김민영·2023년 2월 6일
1

C# 기초 프로그래밍

목록 보기
16/18

🪵 객체지향 프로그래밍이란

  1. 객체를 중심으로 프로그램을 구성하는 방법
  2. 현대 프로그래밍 패러다임 중 가장 주류가 된 프로그래밍 패러다임

🪵 객체지향 프로그래밍의 특징

1. 캡슐화 (Encapsulation)

1) 데이터와 데이터를 다루는 함수를 같이 작성할 수 있게 한 것

  • 필드: 데이터
  • 메소드: 데이터를 다루는 함수

2) 데이터 은닉 (Data Hiding)

  • 접근 한정자를 제공해 외부에 공개할 것과 그렇지 않은 것은 구분
  • 의도에 맞지 않는 데이터 사용을 방지
public class Student
{
    // 필드
    private string name;

    // 메소드
    public void SayName()
    {
        Console.WriteLine($"I'm {name}.");
    }
}

2. 상속 (Inheritance)

▶ 상속이란?

1) 부모 클래스의 코드를 물려받아 재사용할 수 있게 함

  • 더 작은 단위로 모듈화할 수 있음

2) 부모와 자식

  • 부모 클래스 = 기저 클래스
  • 자식 클래스 = 파생 클래스
  • 자식 클래스는 부모 클래스의 private 멤버를 제외하고 모두 상속, 접근 가능
    → 필드는 자식에게도 공개하지 않는 것이 좋은 설계
  • 단일상속만 가능 부모 클래스는 여러개가 될 수 없음
  • 자식 클래스는 부모 클래스보다 더 구체적인 의미를 가져야 함
    → is-a 관계: 자식 클래스 is a 부모 클래스 라는 문장을 만들었을 때 말이 되면 상속 관계를 잘 구성한 것 (ex. Circle is a Shape)
  • 부모의 멤버를 지칭하기 위해 base 키워드를 사용할 수 있음
class Derived : Base
{
    void Foo()
    {
        base._baseMember5 = 1;
    }
}

3) 문법
class 자식 클래스의 식별자 : 부모 클래스의 식별자 { }

class Program
{
    public static void Main()
    {
        Derived d = new Derived();
        d.Foo();
    }
}

class Base
{
    public void Foo() => Console.WriteLine("Foo");
}

class Derived : Base
{

}

→ Derived 클래스에는 Foo()가 존재하지 않지만, 부모 클래스인 Base의 멤버를 물려받으므로 Foo를 호출할 수 있음

  • 실행결과


→ 비주얼스튜디오에서도 Foo는 Base의 메소드임을 확인할 수 있음

4) 생성 순서
: 자식 클래스 인스턴스를 생성하는 경우 부모 - 자식 순으로 생성자가 호출

class Program
{
    public static void Main()
    {
        // 부모 타입의 생성자부터 차례대로 호출됨을 알 수 있다.
        Base3 obj = new Base3();
    }
}

class Base1
{
    public Base1() => Console.WriteLine("Base1");
}

class Base2 : Base1
{
    public Base2() => Console.WriteLine("Base2");
}

class Base3 : Base2
{
    public Base3() => Console.WriteLine("Base3");
}
  • 실행결과


▶ sealed 클래스

: 특정 클래스의 상속을 막고싶은 경우 사용할 수 있는 한정자



3. 다형성 (Polymorphism)

▶ 다형성이란?

1) 같은 코드로 다른 동작을 수행하도록 하는 특성

  • 객체지향 프로그래밍의 핵심
  • 조건문을 암시적으로 만들어 프로그램 확장에 큰 변화를 들이지 않아도 됨

2) 예시
: 캐릭터의 직업군에 따라 물리 데미지가 다른 경우

  • 조건문으로 구현하는 경우
class Program
{
    public static void Main()
    {
        Character warrior = new Character()
        {
            _type = Character.Type.Warrior,
            _atk = 10
        };

        Console.WriteLine($"전사가 줄 수 있는 물리 데미지 : {warrior.GetDamage()}");
        Character magician = new Character()
        {
            _type = Character.Type.Magician,
            _atk = 10
        };
        Console.WriteLine($"마법사가 줄 수 있는 물리 데미지 : {magician.GetDamage()}");
    }
}

public class Character
{
    // 캐릭터의 직업군
    public enum Type
    {
        Warrior,
        Magician
    }

    // 직업군을 따로 데이터로 저장해야 한다.
    public Type _type { get; set; }
    public int _atk { get; set; }
    public int GetDamage()
    {
        // 데미지를 계산하려면 아래와 같이 조건문을 사용해야 한다.
        switch (_type)
        {
            case Type.Warrior:
                return (int)Math.Round(_atk * 1.5);

            case Type.Magician:
                return (int)Math.Round(_atk * 0.8);

            default:
                return 0;
        }
    }
}

→ 직업군이 늘어날 때마다 캐릭터의 직업군 목록을 enum 형식으로 계속 추가해야하고, 각 경우의 case도 추가해야 함

  • 다형성의 특성을 사용해 코드를 작성한 경우
class Program
{
    public static void Main()
    {
        // 서로 다른 타입을 똑같이 다룰 수 있게 된다.
        Character[] characters =
        {
            new Warrior() { Atk = 10 },
            new Magician() { Atk = 10 }
        };

        for (int i = 0; i < characters.Length; ++i)
        {
            Console.WriteLine($"물리 데미지 : {characters[i].GetDamage()}");
        }
    }
}

abstract class Character
{
    public int Atk { get; set; }
    public abstract int GetDamage();
}

// 조건문도 필요 없다.
class Warrior : Character
{
    public override int GetDamage() => (int)Math.Round(Atk * 1.5);
}

class Magician : Character
{
    public override int GetDamage() => (int)Math.Round(Atk * 0.8);
}

→ 직업군 관련 데이터(enum)를 따로 관리하지 않아도 되며, 조건문 없이 어떤 직업군 캐릭터의 동작인지 명확히 알 수 있음

→ 각 직업군 타입의 인스턴스를 생성했을 때 같은 메소드를 호출해도 각 타입별 동작을 수행하게 됨 (다형성)

▶ 업캐스팅과 다운캐스팅

1) 업캐스팅 (Upcasting)

  • 자식 클래스 타입의 인스턴스를 부모 클래스 타입의 변수에 저장할 때 암시적으로 형변환되는 것
// 예시 1
Character[] characters = 
{
	new Warrior() { },
	new Magician() { }
};

// 예시 2 - 확인을 위해 Character 정의 시 abstract 키워드 제외
Character character = new Character();
Warrior warrior = new Warrior();
character = warrior;

→ 업캐스팅 된 상태에서 자식 클래스의 멤버는 사용할 수 없음 (부모 클래스 타입이므로 부모 클래스의 멤버만 사용)

2) 다운캐스팅 (Downcasting)

  • 부모 클래스 타입에 담긴 자식 클래스 인스턴스를 다시 본래 타입으로 다루는 경우
  • 명시적으로 형변환하여 사용
  • 단순히 부모 클래스 타입을 자식 클래스 타입으로 형변환하는 것은 해당하지 않음
// 예시 1
Warrior warrior = (Warrior)characters[0];

// 예시 2
Character character = new Character();
warrior = (Warrior)character;  // 다운캐스팅 아님 그냥 명시적 형변환

▶ is 연산자와 as 연산자

1) 업캐스팅과 다운캐스팅은 상속-파생 관계가 아닌 경우 런타임 오류 발생
→ is 연산자와 as 연산자 사용하여 좀 더 안전한 설계

2) is 연산자

  • expression is type
  • expression의 타입이 type과 호환되는 경우 true, 아닌 경우 false를 반환
if(characters[0] is Warrior)
{
	(Warrior)characters[0];
}

→ characters[0] is Character 도 true

3) as 연산자

  • expression as type
  • expression이 type과 호환되는 경우 해당 타입으로 변환
  • 호환되지 않는 경우 null을 반환
Warrior warrior = characters[1] as Warrior;
Console.WriteLine(warrior?.Def);

→ characters[1]이 Warrior와 호환되지 않으므로 warrior는 null
→ null check에 걸려 이후 동작(Def 값을 받아옴)을 수행하지 않음

▶ 가상 멤버 (Virtual Member)

1) 자식 클래스에서 재정의(오버라이딩)할 수 있는 멤버
2) 메소드, 프로퍼티, 인덱서, 이벤트와 같은 함수들이 가상멤버가 될 수 있음
3) 다형성을 제공하기위한 핵심 기능
4) 자식 클래스에서 재정의(오버라이딩)하기 위해 가상함수의 반환타입 앞에 override 한정자를 붙임

public class Shape
{
	public virtual void Draw()
    {
    	Console.WriteLine("Shape.Draw()");
    }
}

public class Circle : Shape
{
	public override void Draw()
    {
    	Console.WriteLine("Circle.Draw()");
    }
}

▶ sealed 멤버

1) 더이상 재정의하길 원하지 않는 가상 멤버를 재정의할 때 sealed 키워드를 사용

public class Ellipse :Shape
{
	public sealed ovveride void Draw() {}
}

public class Circle : Ellipse
{
	public ovveride void Draw() { }  // 불가, Ellipse.Draw는 재정의 못하도록 sealed
}

4. 추상화 (Abstraction)

▶ 추상화란?

: 구현 세부 정보를 숨기는 일반적인 인터페이스를 정의하는 행위

▶ 추상 클래스와 추상 멤버

1) abstract

  • 추상 클래스 혹은 추상 멤버를 만들 수 있는 한정자 (modifier)

2) 추상 클래스

  • 인스턴스를 만들 수 없음

3) 추상 멤버

  • 메소드, 프로퍼티, 인덱서, 이벤트와 같은 함수들이 추상 멤버가 될 수 있음
  • 가상 멤버(virtual)와의 차이점: 추상 멤버는 자식 클래스에서 반드시 재정의해야 함
    → 설계 의도에 따라 가상 멤버 혹은 추상 멤버를 선택해 사용
    → 재정의 시 override 한정자 사용
  • 부모 클래스에서의 추상 멤버는 동작을 모두 구현하지 않아도 됨
public abstract class Shape
{
    public abstract int Area { get; }
}

class Rectangle : Shape
{
    public int Width { get; set;}
    public int Height { get; set;}

    public override int Area
    {
        get => Width * Height;
    }
}

▶ 인터페이스 (Interface)

1) 동작을 정의하는 용도로 사용
2) 인스턴스를 만드는 것이 불가능
3) 모든 멤버가 추상 멤버로 구성
4) 다중 상속 가능

  • 한번에 여러 인터페이스를 상속받을 수 있음
    5) 식별자의 가장 앞에 I (대문자 I)를 붙임
    → 보통 형용사의 형태로 명명함 (ex. IDrawable)
    6) 접근 한정자를 붙이지 않아도 되며, 기본적으로 public
  • 인터페이스는 단순히 동작을 정의하며, 상속 받은 클래스는 인터페이스의 멤버를 반드시 구현해야하므로 접근한정자를 붙이는 것은 큰 의미가 없음
  • 클래스가 인터페이스의 멤버를 상속받는 것을 구현했다고 표현함
    7) 인터페이스의 멤버를 구현할 때는 override를 작성하지 않음

상속은 단순히 코드 재사용 목적으로만 사용해서는 안된다!

예를들어 Shape 클래스에 Draw() 메소드가 있을 때 Circle은 Shape보다 구체적인 개념으로, Shape를 상속받아 Draw를 재사용하는 자식 클래스가 될 수 있다.
하지만 Person 클래스도 Draw 기능이 필요하다는 이유만으로 Shape를 상속받을 수는 없다는 의미이다.
이럴 때 IDrawable 인터페이스를 만들어 Shape와 Person 클래스 내에서 구현하도록 할 수 있다.

아래 예제를 살펴보자.

class Program
{
    public static void Main()
    {
        Circle circle = new Circle();
        circle.Draw();

    }
}


interface IDrawable
{
    void Draw();
}

public class Person : IDrawable
{
    public void Draw() => Console.WriteLine("IDrawable.Draw() => Person");
}

public class Shape : IDrawable
{
    public void Draw() => Console.WriteLine("IDrawable.Draw() => Shape");
}

실행 결과

1개의 댓글

comment-user-thumbnail
2023년 2월 12일

굉장히 훌륭한 설명이군요

답글 달기