클래스, 구조체, 제네릭, 인터페이스

이준호·2023년 12월 28일
0

클래스, 구조체, 제네릭, 인터페이스



📌 값타입과 참조타입

Vector는 왜 미묘하게 색이 다를까? (Vector : struct, Cursor : Class)

C#의 값타입과 참조타입

C#에서는 값타입과 참조타입 두 가지 타입을 지원한다.

  • 값 타입(Value Type) : int, float, double, bool 등의 기본 데이터 타입들이 값타입에 해당한다. 값타입은 변수에 직접 값을 저장하며, 변수 간의 대입 시 값이 복사된다. 값타입 변수가 다른 변수에 할당되거나 전달될 때는 값이 복사되므로 독립적으로 동작한다.

  • 참조타입(Reference Type) : 클래스, 인터페이스 등이 참조타입에 해당한다. 참조타입은 변수에는 실제 값 대신 객체에 대한 참조(메모리 주소)가 저장된다. 참조타입 변수가 다른 변수에 할당되거나 전달될 때는 참조(메모리주소)가 복사되므로 동일한 객체를 참조하게 된다.






클래스

객체지향 프로그래밍에서 데이터와 메소드를 정의하는 일종의 틀

다양한 데이터를 저장하는 복잡한 자료형

다양한 데이터를 저장할 수 있는 복잡한 자료형

클래스가 가질 수 있는 멤버들 : 메소드, 필드, 프로퍼티(속성)

데이터를 참조 형식으로 저장

얕은 복사 : 내부의 값이 아닌 참조만 복사함으로서 발생하는 문제

해결 : 값 타입이 나올때까지 찾아서 계속 복사해줘야 한다.

의문 : string은 값 타입도 아닌데 문제가 없다?

결론 : 값 형식(구조체 포함) 혹은 String은 문제가 없다. (나머지는 복사시에 굉장히 유의해야 한다.)

사례 1

public class Player
{
    public int health;

    public Player(int initialHealth)
    {
        health = initialHealth;
    }
}

public class Example
{
    void Start()
    {
        Player originalPlayer = new Player(100);
        Player copiedPlayer = originalPlayer;

        copiedPlayer.health = 50;

        // 이 시점에서 originalPlayer의 health도 50으로 변경되어 있음
        Debug.Log(originalPlayer.health);  // 출력: 50
    }
}

사례 2

using System;
using System.Collections.Generic;

public class HelloWorld
{
    public static void Main(string[] args)
    {
        Player originalPlayer = new Player();
        Player copiedPlayer = new Player();
        copiedPlayer.HP = originalPlayer.HP;
        copiedPlayer.inventory = new List<MyClass>();
				for(int i =0; i < originalPlayer.inventory.Count; i++){
						copitedPlayer.inventory.Add(originalPlayer.inventory[i]);
				}
        
        // copiedPlayer의 인벤토리에 'Potion' 추가
        copiedPlayer.inventory.Add("Potion");

        // originalPlayer의 인벤토리에도 'Potion'이 추가된 것을 볼 수 있음
        foreach (string item in originalPlayer.inventory)
        {
            Console.WriteLine(item); // 출력: Sword, Shield, Potion
        }
    }
}


public class Player : IClonable
{
    public List<string> inventory = new List<string>();
    public int HP = 100;
    
		public Player(int Hp, List<string> prev_inventory){
				HP = Hp;
				inventory = prev_inventory;
		}

    public Player()
    {
        inventory.Add("Sword");
        inventory.Add("Shield");
    }

		public Player Clone(){
			 
		}
}

클래스는 상속이 가능하다

클래스는 부모(base)클래스로부터 공통적 부분을 상속하여 새로운 파생 클래스를 만들 수 있다.
상속을 사용하면 base클래스의 데이터와 메서드를 파생클래스에서 사용한다.

상속은 주로 "is a" 관계를 맺고 있다.
ItemManager is a Manager / Monster is a Damagable / Zealot is a GroundUnit

상속의 의의 : 중복된 코드를 줄일 수 있다, 다형성을 통해 일관된 형식(인터페이스)을 공유할 수 있다.
상속의 문법 : 콜론을 찍어서 Base클래스를 명시

// 베이스 클래스
public class Animal
{
   public string Name { get; set; }
   public int Age { get; set; }
}

// 파생클래스
public class Dog : Animal
{       
   public void HowOld() 
   {
      // 베이스 클래스의 Age 속성 사용
      Console.WriteLine("나이: {0}", this.Age);
   }
}

public class Bird : Animal
{       
   public void Fly()
   {
      Console.WriteLine("{0}가 날다", this.Name);
   }
}

상속 FAQ

  • 실제로 Animal 클래스로 객체가 만들어지는 경우를 막고싶다. ➔ 추상클래스
public abstract class Animal // 실제 구현을 강제함 --> interface 비슷
{
    public abstract void Speak();
}

public class Dog : Animal
{
	 // Dog는 무조건 speak을 구현해야함 : 이유는 abstract함수여서
    public override void Speak()
    {
        Debug.Log("Bark");
    }
}
  • 날 수 있다는 특성(flyable)도 상속받도록 하고 싶다. ➔ 인터페이스
public interface IFlyable
{
    void Fly();
}

public class Bird : Animal, IFlyable
{
    public override void Speak()
    {
        Debug.Log("Chirp");
    }

    public void Fly()
    {
        Debug.Log("Bird is flying");
    }
}
  • 부모 클래스에서는 A처럼 작동했지만 자식 클래스에서는 A'처럼 행동하고 싶다. ➔ virtual / override
public class Animal
{
    public virtual void Speak() // 실제 구현을 강제하지 않음. 그냥 기본 구현(default값)
    {
        Debug.Log("Animal sound");
    }
}

public class Cat : Animal
{
    public override void Speak()
    {
        Debug.Log("Meow");
    }
}

public class Dog : Animal // Speak를 했을 때 오류 안남 왜냐면 기본 구현이 있기 때문
{
	
}
  • 여러 자식 클래스들이 섞여들어있는 컬렉션에서 특정 클래스인지 확인하고 싶다. ➔ is / as
public void CheckAnimalType(Animal animal)
{
    if (animal is Dog)	// animal(변수에 담긴 클래스)가 Dog라면
    {
    	// Anumal클래스인 animal변수를 명시적으로 타입 케스팅 하고 dog변수로 받는다.
        Dog dog = animal as Dog;	// animal 변수를 Dog 클래스로 지정한다.
        dog.Speak();  // Bark
    }
    else if (animal is Cat)
    {
        Cat cat = (Cat)animal;
        cat.Speak();  // Meow
    }
}
  • 부모 클래스의 일부 멤버를 자식 클래스에 전달하고 싶지 않다. ➔ private
public class Animal
{
    private int age;

    public void SetAge(int age)
    {
        this.age = age;
    }
}

public class Dog : Animal
{
    public void PrintAge()
    {
        // age 접근 불가능. 컴파일 오류 발생
        // Debug.Log(age);
    }
}
  • 부모 클래스의 일부 멤버를 외부 클래스에는 노출하고 싶지 않지만 자식 클래스에게는 전달하고 싶다. ➔ protected
public class Animal
{
    protected int age;

    public void SetAge(int age)
    {
        this.age = age;
    }
}

public class Dog : Animal
{
    public void PrintAge()
    {
        Debug.Log(age);  // 접근 가능
    }
}

클래스 VS 구조체

특징클래스구조체
키워드classstruct
형식참조타입값 타입
복사얕은 복사깊은 복사
인스턴스 생성new 연산자선언만으로 생성
상속가능불가능
인터페이스는 됨





[더 배우기] is a 관계가 말이 되지만 문제가 되는 상황들

리스코프 치환 원칙 (Liskov Substitution Principle)
리스코프 치환 원칙은 객체지향 프로그래밍에서 상속과 다형성을 이용할 때 지켜야 할 원칙 중 하나이다. 리스코프 치환 원칙은 다음과 같이 정의된다.

"프로그램의 객체들은 그것들의 서브타입(subtype) 객체로 대체될 수 있어야 한다."

즉, 어떤 클래스가 상속관계에 있을 때, 부모 클래스의 인스턴스 대신에 자식 클래스의 인스턴스를 사용해도 프로그램의 의미나 동작에 아무런 영향이 없어야 한다.

하지만 때로는 상속을 받은 것처럼 보이지만 실제로는 리스코프 치환 원칙을 위배하면서 문제가 발생할 수 있다.

예시 1

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }

    public int Area()
    {
        return Width * Height;
    }
}

public class Square : Rectangle
{
    public override int Width
    {
        get { return base.Width; }
        set
        {
            base.Width = value;
            base.Height = value;
        }
    }

    public override int Height
    {
        get { return base.Height; }
        set
        {
            base.Width = value;
            base.Height = value;
        }
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        Rectangle rectangle = new Square();
        rectangle.Width = 5;
        rectangle.Height = 10;

        Console.WriteLine(rectangle.Area());  // 출력: 100
    }
}

위의 예시에서는 사각형(Rectangle) 클래스와 정사각형(Square) 클래스가 상속 관계에 있다. 하지만 정사각형은 가로와 세로를 항상 같은 길이로 유지해야 하기 때문에, 가로와 세로를 따로 설정할 수 없게 되어 리스코프 치환 원칙을 위배하게 된다. 이로 인해 사각형의 넓이를 계산하는 Area() 메소드가 예상과 다른 결과를 출력하게 된다.

예시 2

public abstract class Animal
{
    public abstract void MakeSound();
}

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

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

public class Program
{
    public static void Main(string[] args)
    {
        Animal animal = GetRandomAnimal();
        animal.MakeSound();
    }

    public static Animal GetRandomAnimal()
    {
        Random random = new Random();
        int randomNumber = random.Next(1, 3);

        if (randomNumber == 1)
        {
            return new Dog();
        }
        else
        {
            return new Cat();
        }
    }
}

위의 예시에서는 동물(Animal) 클래스를 추상 클래스로 선언하고, 강아지(Dog) 클래스와 고양이(Cat) 클래스가 상속 관게에 있다. 그리고 GetRandomAnimal() 메소드에서는 무작위로 동물을 선택하여 반환한다. 하지만 이러한 구조에서는 리스코프 치환 원칙이 깨진다.
GetRandomAnimal() 메소드에서는 반환 타입이 동물(Animal) 클래스로 정의되어 있지만, 실제로는 동물의 하위 클래스인 강아지(Dog) 클래스나 고양이(Cat) 클래스가 반환될 수 있기 때문이다. 이로 인해 예상치 못한 동작이 발생할 수 있다.

예시 3

public class Bird
{
    public virtual void Fly()
    {
        Console.WriteLine("The bird is flying");
    }
}

public class Penguin : Bird
{
    public override void Fly()
    {
        throw new NotImplementedException();
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        Bird bird = new Penguin();
        bird.Fly();
    }
}

위의 예시에서는 새(Bird) 클래스와 펭귄(Penguin) 클래스가 상속 관계에 있다. 새 클래스에서는 Fly() 메소드를 가지고 있지만, 펭귄 클래스에서는 이 메소드를 오버라이드 하여 구현하지 않았다. 이러한 상황에서는 펭귄 객체를 새 객체로 간주하는 상황이 발생할 수 있다. 즉, 리스코프 치환 원칙을 위배하게 된다. 이로 인해 예외가 발생하게 된다.

리스코프 치환 원칙을 위배하면 예상치 못한 동작이나 예외가 발생할 수 있다. 예를 들어, 부모 클래스의 메서드를 오버라이딩 하여 자식 클래스에서 다른 동작을 수행하는 경우, 이는 리스코프 치환 원칙을 위배한다. 이로 인해 코드의 가독성이 떨어지고, 예상치 못한 결과가 발생할 수 있다.

또한, 자식 클래스가 부모 클래스의 일부 멤버를 숨기지 않고 노출하거나 변경하는 경우도 리스코프 치환 원칙을 위배할 수 있다. 이는 다형성을 사용하는 클라이언트 코드에서 예외를 방생시킬 수 있다. 클라이언트 코드가 부모 클래스의 인스턴스를 사용하는 것이 예상되는 상황에서 자식 클래스의 인스턴스를 사용하면 예상치 못한 동작이 발생할 수 있다.

따라서, 리스코프 치환 원칙을 지키지 않는다면 코드의 예측 가능성과 유지 보수성이 떨어지며, 오류 발생 가능성이 증가할 수 있다.












📌 제네릭 : 마법상자

제네릭은 마치 마법상자처럼 다양한 타입을 담을 수 있는 기능이다. 제니릭을 사용하면 코드의 재사용성과 유연성을 높일 수 있다. 타입에 대한 제한이 없이 여러 종류의 데이터를 한꺼번에 처리할 수 있어 개발을 더욱 효율적으로 만들어준다. 이러한 제너릭은 마치 마법처럼 다양한 상황에서 사용할 수 있는 강력한 도구이다.

제너릭은 데이터 타입을 미리 지정하지 않고 다양한 타입을 지원하는 코드를 작성할 수 있게 해준다. 제너릭을 사용하면 코드의 재사용성과 유지보수성을 향상시킬 수 있다.

예를 들어, List<T> 클래스는 제너릭을 사용하여 다양한 데이터 타입의 리스트를 생성할 수 있다. T는 사용자가 지정한 타입에 따라서 다양한 형태의 리스트를 만들 수 있다. 아래는 제너릭을 사용한 List<T> 클래스의 예시이다.

List<int> numbers = new List<int>();
numbers.Add(1);
numbers.Add(2);
numbers.Add(3);

List<string> names = new List<string>();
names.Add("John");
names.Add("Jane");
names.Add("Mike");

위의 예시에서 List<int>는 int의 리스트를, List<string>은 string의 리스트를 생성한다.
제너릭을 사용하면 동일한 코드를 다양한타입에 대해 재사용할 수 있다.

제너릭을 사용하여 사용자 정의 클래스나 메소드를 작성할 수도 있다.
아래는 제너릭을 사용한 메소드의 예시이다.

public T GetMax<T>(T a, T b)
{
    if (a.CompareTo(b) > 0)
    {
        return a;
    }
    else
    {
        return b;
    }
}

int maxInt = GetMax<int>(3, 5);
string maxString = GetMax<string>("apple", "banana");

위의 예시에서 GetMax<T> 메소드는 제너릭으로 작성되어 있다. 사용자가 지정한 데이터 타입에 따라서 최댓값을 반환하는 메소드이다. GetMax<int>는 정수형 데이터에 대한 최댓값을, GetMax<string>은 문자열 데이터에 대한 최댓값을 반환한다.

제네릭의 제한

하지만, 사실 대부분의 경우에서는 아무거나 막 넣을 수 있도록 허락하지 않는다. 더하기를 한다고 하는데 클래스를 넣어버리고 이러면 곤란할거다. 그래서 제너릭에서는 제한을 걸 수 있도록 한다.
제너릭에서 제한을 거는 부분은 where 절을 사용하여 구현할 수 있다. where 절을 사용하면 제네릭 타입 매개변수에 대한 제약 조건을 지정할 수 있다.

예를 들어, TMonster 클래스를 상속받은 클래스여야 한다는 제약 조건을 걸고 싶다면 다음과 같이 작성할 수 있다

public class Example<T> where T : Monster
{
    // ...
}

위의 예시에서 Example<T> 클래스는 TMonster클래스 또는 Monster클래스를 상속받은 클래스여야 한다. 이렇게 하면 TMonster클래스로 제한되며, Monster클래스의 멤버에 접근할 수 있다.












📌 인터페이스

사용하는 이유: 클래스만 쓸 때보다 훨씬 유연성과 확장성 있는 코드를 구현하기 위해서

  • 인터페이스는 클래스가 구현해야 하는 동작을 정의하는 계약서이다. 즉, 인터페이스는 클래스에 어떤 동작을 구현해야 하는지 알려준다. 이를 통해 클래스 간에 일관된 동작을 보장할 수 있다.

  • 인터페이스는 C#에서 다형성을 구현하는 중요한 개념이다. 다형성이란, 동일한 메서드를 가지고 있지만 다른 방식으로 동작하는 여러 개의 객체를 사용할 수 있는 것을 말한다.

  • 인터페이스는 클래스의 형태를 갖지 않고, 동작에 대한 규약을 정의한다. 클래스는 이러한 인터페이스를 구현함으로써 인터페이스에 정의된 동작을 보장하게 된다.

  • 인터페이스를 사용하면 코드의 재사용성과 유연성을 높일 수 있다. 인터페이스를 구현한 클래스들은 동일한 동작을 제공하므로, 클라이언트 코드는 구현체의 내부 구현에 대해 알 필요가 없다. 이를 통해 코드의 결합도를 낮출 수 있으며, 유지 보수성을 향상시킬 수 있다.

  • 인터페이스를 구현하려면 인터페이스에 정의된 모든 동작을 구현해야 한다. 이를 통해 클래스는 인터페이스에 명시된 동작을 보장하게 되며, 다형성을 구현할 수 있다.

  • 인터페이스는 다중 상속을 흉내내는 기능을 제공하여 여러 개의 인터페이스를 동시에 구현할 수 있다. 이를 통해 클래스는 다양한 동작을 갖는 객체를 생성할 수 있고, 코드의 재사용성과 유연성을 극대화할 수 있다.

  • C#에서 인터페이스를 잘 활용하면 유연하고 확장 가능한 코드를 작성할 수 있다. 인터페이스는 객체지향 프로그래밍에서 중요한 역할을 하며, 다형성을 통해 코드의 유연성을 높일 수 있다.

비교클래스 (Class)인터페이스 (Interface)
정의데이터와 메서드의 집합으로 구성된 타입동작에 대한 규약을 정의하는 계약서
다중 상속단일 상속만 가능다중 인터페이스 구현 가능
생성자생성자를 가질 수 있음생성자를 가질 수 없음
필드인스턴스 필드와 정적 필드 지정 가능인터페이스는 필드를 가질 수 없음
메서드일반 메서드, 가상 메서드, 추상 메서드 가능모든 메서드는 추상 메서드로 선언되어야 함
속성속성 지정 가능속성 지정 가능
profile
No Easy Day

0개의 댓글