객체 지향 프로그래밍(OOP)의 특징과 클래스와 객체에 대해 서술하는 시간을 가져볼 것이다.
객체지향 프로그래밍의 특징
캡슐화
관련된 데이터와 기능을 하나의 단위로 묶어 사용한다.
캡슐로 된 약을 생각해보자
두통약도 있고 해열제도 있고 진통제도 있을 것이다. 각자 해야할 역할(기능)에 따라 캡슐화했으며 캡슐을 부수지 않는 내용물을 볼 수가 없다.
캡슐화를 통해 정보를 은닉이 가능하고 외부에서의 직접적인 접근을 제한할 수 있는 것이며
만일 어떤 약에 문제가 생기면 각각의 것만 수정하면 되기에 각각의 안정성과 유지보수성을 높일 수 있다.
또한 기능 별로 묶여있기에 필요한 기능이 있다면 새롭게 구현하는 것이 아닌 포함만 하면 되는 것이다.
두통도 해열도 하고 싶으면 새롭게 만드는게 아니라 기능 별로 나누어져있으니 두 약을 포함하면 된다는 것이다.
상속
사람이 있으면 직장인도 있고 학생도 있지만 결국 직장인도 학생도 둘다 사람이다.
사람이라는 공통된 부분을 부모 클래스(상위 클래스)로 가지고 있고 각자 직장인이 할 수 있는 일과 학생이 할 수 있는 일이 다르기에 이를 자식 클래스에서 구현을 하는 것이다.
공통된 부분이 있기에 코드간의 중복도 줄이고 계층 구조로 만들 수 있기에 코드의 구조화와 유지보수에 도움을 줄 수 있다.
다형성
영어로는 Polymorphism인데 뜻을 나누어보면 Poly(여럿의)+morph(형체)이다.
뜻대로 하나의 요소(메서드, 객체 등)가 여러 형태를 가지게 됨을 의미한다.
오버로딩과 오버라이딩과 같이 하나의 메서드 이름이 매개변수에 따라 혹은 객체에 따라 다르게 동작할 수 있도록 한다.
동일한 코드에서 다양한 처리가 이루어 질 수 있도록함
하나의 객체가 여러 형태를 가지게 된다는 건 무슨 의미일까? 이는 상속과 연관되어 있는데 아래에 설명을 하겠다.
추상화
추상적이라는 것은 구체화되지 않았다는 것
복잡한 시스템이나 개념을 단순화하여 세부 구현 내용은 감추고 핵심적인 개념이나 기능에 집중하는 것이다.
객체
추상적이라는 것은 구체화되지 않은 것이라면 객체가 바로 구체적이고 실체가 있는 것이다.
객체는 클래스로부터 생성된 실체이며 각 객체는 독립적인 상태를 가지고 있다.
클래스로 구현되었기에 데이터와 기능/논리(메서드)를 가지고 있으며 객체 간의 상호작용을 통해 프로그램이 동작하고 모듈화와 재사용성을 높일 수가 있다.
클래스
클래스 구성요소
필드: 멤버 변수 (데이터)
메서드: 멤버 함수 (기능/논리)
생성자: 객체가 생성될 때 자동으로 호출
소멸자: 객체가 소멸될 때 호출되는 메서드
생성자도 소멸자도 결국 메서드라 큰 구성요소는 필드와 메서드라 할 수 있다.
클래스는 객체를 생성하기 위한 설계도(템플릿)이다.
클래스를 사용하여 객체인 인스턴스를 만들 수가 있다.
붕어빵 틀에 많이 비유하는데 우리가 붕어빵 틀을 먹는게 아니라 붕어빵을 먹는다. 붕어빵 틀에서 찍어낸 붕어빵들이 객체이고 붕어빵 객체들마다 슈크림, 팥, 고구마 등 맛이 다를 수가 있다. 각자 독립된 상태에서 데이터를 가지고 있다는 것이다.
당연하게도 클래스도 구조체와 마찬가지로 사용자 정의 타입이다.
위는 클래스 내부 모습을 그림으로 표현한 모습이다.
크게 데이터와 기능/논리로 나누어 볼 수 있고 필드와 메서드로 나누어져 있다 봐도 된다.
기능엔 알고리즘이 들어가고 데이터에선 알고리즘을 짜기위해서 준비되어져야 하는 데이터를 세팅한다.
정적인 것들이 데이터(필드), 데이터를 움직이게 바꾸는 동사에 해당되는게 기능/논리(메서드)로 생각하면 편하다.
데이터는 변수로 저장하는데 이 변수라는 것은 단일데이터가 될 수도 단일데이터가 여러개 모여있는 배열/리스트 같은 자료구조가 될 수도 있고 구조체/클래스 같은 객체 변수가 되어 그 안의 데이터와 메서드를 꺼내 쓸 수 있는 것이다.
클래스 선언
class Person
{
public string Name;
public int Age;
public void PrintInfo()
{
Console.WriteLine("Name: " + Name);
Console.WriteLine("Age: " + Age);
}
}
Person p = new Person();
p.Name = "John";
p.Age = 30;
p.PrintInfo(); // 출력: Name: John, Age: 30
Person 클래스를 만들어 사용하는 위의 예시를 보자
클래스 선언은 class "클래스명" {} 으로 선언하고 중괄호 안에 필드와 메서드를 채워나가면 된다.
구조체와 클래스의 차이점
만일 Person이 구조체였다면 Person p; 하고 사용하면 된다.
하지만 클래스는 new라는 키워드를 통해 동적할당하여 생성하는 것을 알 수 있다.
이는 구조체는 값(value) 형식이지만 클래스는 래퍼런스 형식이기 때문이다.
객체의 데이터 값들은 메모리의 Heap 영역에 저장이되고 그 영역에 대한 주소값을 변수 p에 담아두는 것이다.
변수 p는 Stack에 있어 데이터를 가리키고 있다.
즉 포인터로 생각해도 좋다. 스택에 포인터 변수를 두어 힙 영역의 데이터를 가리키도록 한 것이다. 참조를 하는 것이다.
구조체는 값 형식으로 그냥 스택에 할당되고 복사할 때는 값이 복사가 된다.
구조체 선언하면서 바로 변수가 되지만 클래스는 객체화를 진행하고 연결을 해야지만 사용이 가능한 것이다.
또한 구조체는 상속이 불가한 반면 클래스는 단일, 다중 상속이 가능하다.
작은 크기의 데이터 저장이나 단순한 데이터 구조면 구조체가 유리하고 좀 더 복잡하면 클래스를 사용하는 것이 좋다.
허나 사용방법은 같음
. 을 이용하여 안에 있는 변수(public)나 메서드를 사용할 수 있다.
클래스 대입
만일 Card 클래스가 있고 card1과 card2라는 2개의 Card 클래스 객체를 만들었다고 해보자
Card card1 = new Card();
Card card2 = new Card();
card1 = card2;
이렇게 card1에다가 card2를 대입시키면 어찌 될까...?
대입 전에는 위의 그림과 같이 card1과 card2의 변수가 각자의 데이터가 차지하고 있는 힙영역을 가리키고 있을 것이다.
card1 = card2를 하면 card1이 가리키고 있던 연결을 끊고 card2가 참조하고 있던 영역을 가리키게 된다.
이제 card1에서 값을 수정하면 card2에도 영향을 받게 되는 것이다.
반면 구조체는 대입하면 값을 복사하여 스택에 따로 자기 영역에 보관하기에 card1을 수정한다고 card2가 바뀌진 않는다.
접근 제한자 (Access)
public: 클래스 외부에서도 자유롭게 접근 가능
private: 클래스 내부에서만 접근할 수 있음
protected: 클래스 내부와 상속을 통해 나로부터 파생된 애들(자식)만 접근할 수 있음
보통은 보안을 생각하여 private로 쓴다.
생성자
public Person(string newName, int newAge)
{
name = newName;
age = newAge;
}
객체를 초기화하고 필요한 초기값을 설정하는 메서드다.
객체가 생성될 때 자동으로 호출되며 반환타입이 없고 클래스와 동일한 이름을 가진다.
메서드 오버로딩을 통해 매개변수의 개수와 자료형에 따라 다른 생성자를 호출 할 수 있음.
생성자를 적지않아도 default 생성자를 호출한다. 허나 오버로딩을 통해 매개변수가 있는 생성자 메서드를 작성했다면 기본 생성자 메서드는 만들어지지 않게 된다. 고로 생성자 오버로딩 시엔 매개변수가 없는 경우도 생각해서 default 생성자도 만들어 놓는 것이 좋다.
소멸자
객체가 소멸이 될 때 자동적으로 호출이된다.
클래스와 동일한 이름을 가지고 반환 타입이 없으며 이름 앞에 ~를 붙인다.
C#에서는 가비지 컬렉터에 의해 사용되지 않는 영역은 메모리 해제를 해준다.
~Person()
{
Console.WriteLine("Person 객체 소멸");
}
프로퍼티
필드값들이 은닉화를 하면 외부에서 접근이 안되는데 이를 외부에서도 간접적으로 접근할 수 있도록 매개자 역할을 한다.
접근제한자가 있긴하지만 외부에서 접근 가능하게 하려면 프로퍼티에선 public으로 하는 것이 좋다.
또한 연결하고자 하는 멤버 변수와 동일한 자료형으로 해야한다. 이름도 동일하게 하되 첫글자만 대문자로 구분해주는 것이 좋다.
프로퍼티에는 get과 set이 있다.
get은 멤버 변수 값을 return을 해주는거고 set은 value 값을 받아와 멤버 변수 값을 value 값으로 설정한다.
아래는 사용법이다.
class Person
{
private string name;
public string Name
{
get { return name; }
set { name = value; }
}
}
Person person = new Person();
person.Name = "John";
Console.WriteLine($"Name is {person.Name}");
private 로 선언된 name의 값을 프러퍼티를 통해 person.Name으로 가져오고 특정 값으로 설정하는 것을 확인할 수 있다.
public 변수 쓰듯이 쓰면 된다.
프러퍼티 접근 제한자 적용
class Person
{
private string name;
public string Name
{
get { return name; }
private set { name = value; }
}
}
set을 private로 적용해 막아둠, 프로퍼티 자체는 외부에서도 접근이 가능하지만 세팅은 클래스 내부에서만 하겠다고 제안을 둔 것이다. 값을 가져오는 것만 가능하도록 한 것이다.
유효성 검사
class Person
{
private string name;
public string Name
{
get { return name; }
set
{
if (value >= 0)
age = value;
}
}
}
나이는 최소 0살은 돼야 하니 0살 이상인지 유효성을 검사해줄 수 있다. 음수와 같이 조건에 안맞으면 동작하지 않고 넘어간다.
value 값이 유효한지 확인할 수 있다는 것이다.
실제로도 많이 쓰는 기능이다.
자동 프로퍼티
class Person
{
public string Name { get; set; }
}
심지어 필드도 작성하지 않고 위와 같이 작성할 수 있다.
코드도 줄일 수 있고 필드 역할도 진행이 된다.
필요할 때 분리해서 사용하는 것도 가능하니 미리 구현해놓는 것이 좋다.
class Person
{
public string Name { get; set; } = "bugmin";
}
이런식으로 바로 초기화하는 문법도 있다고 한다.
클래스에서의 상속
클래스 다중 상속은 C#서 지원하지 않고 인터페이스 상속시에만 다중 상속을 지원한다. 하나의 클래스와 여러개의 인터페이스를 상속 받을 수 있다.
자식 클래스에선 부모 클래스의 메서드를 재정의하여 다형성을 구현할 수 있음
상속의 계층 구조를 형성할 때 상속의 깊이가 깊어질 수록 클래스 간의 관계가 복잡해질 수 있어 적절한 상속의 깊이를 유지하는 것이 좋다.
// 부모 클래스
public class Animal
{
public string Name { get; set; }
public int Age { get; set; }
public void Eat()
{
Console.WriteLine("Animal is eating.");
}
public void Sleep()
{
Console.WriteLine("Animal is sleeping.");
}
}
// 자식 클래스
public class Dog : Animal
{
public void Bark()
{
Console.WriteLine("Dog is bark");
}
}
static void Main(string[] args)
{
Dog dog = new Dog();
dog.Name = "Bobby";
dog.Age = 3;
dog.Eat();
dog.Sleep();
dog.Bark();
}
Dog 클래스는 Animal 클래스를 상속 받았기에 Animal이 가진 필드를 프로퍼티를 통해 사용할 수 있고 public이나 protected로 선언된 메서드는 사용할 수가 있다.
또한 Dog 클래스가 가진 Bark 메서드도 사용이 가능하다.
저장된 메모리 구조를 보면 앞부분에 Animal 부분이 있고 뒤에 Dog 만 가지고 있는 부분이 있는 것이다.
메서드 하이딩(Method Hiding)
// 부모 클래스
public class Animal
{
public string Name { get; set; }
public int Age { get; set; }
public void Sleep()
{
Console.WriteLine("Animal is sleeping.");
}
}
// 자식 클래스
public class Dog : Animal
{
public void Sleep()
{
Console.WriteLine("Dog is sleeping.");
}
}
static void Main(string[] args)
{
Dog dog = new Dog();
dog.Name = "Bobby";
dog.Age = 3;
dog.Sleep();
}
만일 위의 코드처럼 부모 클래스에 있는 Sleep 메서드를 자식 클래스에서 재정의해서 쓰면 어떻게 될까?
의도대로 dog.Sleep()를 하면 "Dog is sleeping"이 출력될 것이다.
하지만 이는 Dog 클래스 내에서 Sleep 메서드를 새로 선언하는 것으로 간주하고 부모인 Animal 클래스의 Sleep 메서드를 숨겨버린다.
이것이 "Method Hiding"이다. "Method Hiding"을 할 때는 명시적으로 접근제한자 뒤에 new를 붙여주는 것이 좋다.
public new void Sleep() {...}
이런식으로 new를 붙여주자
(Animal)dog.Sleep() 로 형변환 하면 부모인 Animal 의 Sleep 함수를 수행할 수도 있다.
허나
문제 상황은 아래와 같다.
static void Main(string[] args)
{
Animal dog = new Dog();
dog.Name = "Bobby";
dog.Age = 3;
dog.Sleep();
}
만일 이런 경우엔 결과는 어찌나올까? 부모인 Animal의 Sleep 함수가 호출된다.
이전에 언급했듯이 메모리 구조를 보면 앞에 Animal의 부분이 있고 Dog의 Sleep 메서드도 있다.
하지만 우리는 dog를 Animal로 선언했다.
고로 Animal 의 Sleep 함수를 택하게 된 것이다.
이를 메모리 상황에서보자
부모인 Animal 에서 상속받은 부분이 있고 Dog만의 부분도 있다.
고로 Animal 부분에도 Sleep 메서드가 있고 Dog 부분에도 Sleep 메서드가 있다.
허나 변수를 Dog로 선언했기 때문에 Animal의 Sleep 메서드는 숨기고 Dog의 Sleep 메서드를 선택한다.
허나 반대로 변수를 Animal로 선언한다면 Dog의 Sleep 메서드는 숨기고 Animal의 Sleep 메서드를 선택하게 된다.
즉 부모 클래스로 선언하여 자식의 메서드를 참조하려하면 하이딩 방식은 부모쪽에 선언된 메서드밖에 볼 수 없다는 것이다.
이는 다형성에 있어 안좋은 것이다.
다형성은 같은 타입이지만 다양한 동작을 수행할 수 있도록 하는 것인데
하이딩 방식으로 메서드를 구현하면
만일 Cat이라는 또다른 자식 클래스가 있고 메서드 하이딩을 통해 Cat 만의 "Cat is sleeping"을 출력하는 Sleep 함수가 있다하면
Animal dog = new Dog();
Animal cat = new Cat();
dog.Sleep();
cat.Sleep();
아래 두 함수는 부모인 Animal의 Sleep 메서드를 호출하게 될 것이다.
같은 타입(자료형)이지만 다양한 동작을 수행하지 못하고 자신의 Sleep 메서드만 호출하는 모습이다.
만일 부모가 가지고 있는 메서드와 동일한 메서드를 자식에서 다르게 쓰고 싶다면 그대로 쓰면 다형성을 해칠 수 있기에 새로운 방식을 써야한다.
그 방식이 바로 Virtual 메서드이다.
Virtual(가상) 메서드
다형성을 위해 같은 자료형이라도 다양한 동작을 수행할 수 있도록 하자
virtual(가상) 메서드는 부모 클래스서 정의되고 자식 클래스에서 필요에 따라 재정의할 수 있는 메서드이다. 즉, 꼭 재정의를 안해도 된다.
public class Unit
{
public virtual void Move() // 자식이 재정의 했을 수 있다
{
Console.WriteLine("두발로 걷기");
}
//virtual -> 실형태가 다를 수 있으니 실형태에 재정의 되어있는지 확인해봐라
//재정의를 안했으면 내 것(부모의 것)을 사용하면 됨
//재정의했다면 자식의 것을 씀
public void Attack()
{
Console.WriteLine("Unit 공격");
}
}
public class Marine : Unit
{
}
public class Zergling : Unit
{
public override void Move()
{
Console.WriteLine("네 발로 걷기");
}
}
static void Main(string[] args)
{
//Unit은 참조의 형태고 Marine과 Zergling은 실형태, 실제로 메모리에 잡혀있음
//참조의 형태로 접근을 할때 virtual이 있다면 내가 Unit이지만 실형태는 다를 수 있으니 실형태에 가봐서 확인해보라 애기해줌
List<Unit> list = new List<Unit>();
list.Add(new Marine());
list.Add(new Zergling());
foreach(Unit unit in list)
{
unit.Move();
}
}
부모 클래스에선 메서드의 반환값 접근제한자 뒤에 virtual를 붙여 메서드를 선언하고
자식 클래스에선 override를 붙여 재정의 해주면 된다.
재정의를 안하면 부모 클래스의 메서드를 따라가니 언급했듯이 재정의는 꼭 하지 않아도 된다.
근데 왜 굳이 마린과 저글링을 따로 따로 만들면되지 유닛으로 관리할까?
마린 100마리, 저글링 100마리면 너무 많으니 구분해서 처리하기엔 복잡해짐
고로 Unit이라는 하나의 클래스로 관리를 하겠다는 것이다.
이전에 소개한 메서드 하이딩으로 구현하면 Unit 클래스로 관리하면 설명했듯이 전부 Unit 클래스의 메서드를 호출하기에 가상 메서드를 사용하는 것이다.
Unit은 참조의 형태고 Marine과 Zergling은 실형태로써 실제로 메모리에 잡혀있다.
참조의 형태로 접근을 할때 가상 메서드가 있다면 내가 참조의 형태는 Unit이지만 실제 메모리에 잡혀있는 실형태는 다를 수 있으니 실형태에 가서 확인해보라 알려준다.
즉 가상 메서드의 역할은 자식이 재정의를 했을 수 있다는 것을 알려주는 것이고 자식 클래스에선 override로 필요에 따라 재정의 해주면 된다.
이것을 반대로 말하면 가상 메서드가 없으면 자식 클래스에 있는 메서드를 사용할 수 없다는 것이다. 왜냐면 어찌되었건 참조의 형태는 부모 클래스이기 때문에 부모 클래스 내에 없는 메서드라면 참조하는 입장서는 알려주지 않으면 알길이 없기 때문이다.
요약
부모 클래스로만 관리를 하여 참조의 형태와 실형태가 다를 때 다형성을 이용하고자 한다면 부모 클래스에서 가상(virtual) 메서드를 만들고 자식 클래스에서 override로 재정의 해주면 된다. 가상 메서드는 해당 메서드가 실형태(자식 클래스)에서 재정의 되어있을 수 있음을 알려주고 만일 재정의를 안했다면 부모 클래스의 메서드 내용을 수행하니 꼭 재정의를 해야하는 것은 아니다.
이로써 부모 클래스라는 하나의 형태로써 관리를 하는데 실제 동작하는 과정에선 다양하게 동작하고 있게 된다.
다형성과 관해 좀 더 비유를 들어 이해해보면
개는 동물이지만 모든 동물은 개가 아니다. 고양이는 동물이지만 모든 동물은 고양이가 아니다.
즉 동물 중에는 강아지도 있을 수 있고 고양이도 있을 수가 있다.
고로 동물 클래스를 참조의 형태로써 실제 메모리에 있는 실형태의 강아지나 고양이 인스턴스를 참조(래퍼런스)하여 관리하자는 것이다.
가상메서드를 통해 하나의 부모 객체가 여러 자식 형태의 인스턴스를 참조할 수가 있게 되는 다형성을 실현하자
추상 클래스
추상 클래스는 직접적으로 객체를 만들 수는 없다.
기점으로 만드는, 베이스 클래스로써 사용이 된다.
abstract 키워드를 쓰며, 추상 메서드를 포함할 수 있다.
추상 메서드는 추상 클래스 내에서만 사용되며 그냥 선언만 하고 구현을 안함, 추상 메서드에 대한 구현은 상속 받은 자식 클래스에서 무조건 구현을 해야한다고 강제성을 부여한다. 구현을 안하면 에러가 난다.
abstract class Shape
{
public abstract void Draw();
}
class Circle : Shape
{
public override void Draw()
{
Console.WriteLine("Drawing a Circle");
}
}
class Square : Shape
{
public override void Draw()
{
Console.WriteLine("Drawing a Square");
}
}
static void Main(string[] args)
{
List<Shape> list = new List<Shape>();
list.Add(new Circle());
list.Add(new Square());
foreach (Shape shape in list)
{
shape.Draw();
}
}
가상 메서드와 달리 추상 메서드는 자식에서 구현할 때 override를 쓰는 것은 동일하지만 강제성이 부여되어 상속받는 자식 클래스라면 무조건 구현을 해야한다.
C++의 순수 가상 함수와 비슷한 역할을 한다고 생각해도 좋다.
오버라이딩과 오버로딩
오버라이딩(Overriding)
부모 클래스에서 정의 되어있는 메서드를 자식 클래스에서 재정의 하는 것이다.
부모 클래스의 메서드와 메서드 이름, 반환타입, 매개 변수가 동일해야한다.
가상 메서드: 필요에 따라 재정의함
추상 메서드: 반드시 재정의해야함
오버로딩(Overloading)
매개변수의 개수와 타입이 다른 같은 이름의 메서드들을 여러 개를 정의할 수 있는 것이다.
반환 값과 메서드 이름은 같아야 한다.
한 줄로 말하면 오버라이딩은 함수를 덮어 쓰는 것이고 오버로딩은 함수를 읽어올때 쓰는 것이다.
'C#' 카테고리의 다른 글
5/1 TIL - Delegate, Func, Action, 람다 (2) | 2024.05.01 |
---|---|
C# 인터페이스와 열거형 (1) | 2024.04.26 |
C# 고급 문법 및 기능 (0) | 2024.04.25 |
C# 연산자와 문자열 처리 (0) | 2024.04.23 |
C# 입출력과 각종 용어들 (0) | 2024.04.22 |