본문 바로가기

Swift/Deep dive!!

[Swift] OOP. ObjectOrientedProgramming의 객체 설계 파해치기!!! | struct vs class | Class 설계 탐구하기 +_+

 

안녕하세요!

 

오랜만에 OOP를 공부하면서 캡슐화, 상속, class vs struct, 다형성, Composition(조합)등의 잠시 잊고 있었던 개념을 머릿속으로 불러오게 되었습니다. 바이시클 카드, 포커 카드 등 다양한 카드의 parent가 될 수 있는 Card클래스를 구현해봤습니다.

ObjectiOrientedProgramming

간략하게 OOP(ObjectOrientedProgramming)를 소개하자면 객체라는 기본 단위를 모델링하고 이를 다양한 곳에서 원하는 목적에 맞게 상속받아서 사용하는 것입니다. 모델링이라는 개념은 현실 세계의 개념을 추상화해서 우리가 개발하는 xcode에서 swift 언어로 클래스와 프로퍼티가 있는 객체를 만드는 과정을 의미합니다. (관련 DB 개념 정리 링크)

 

cf. 참고..

모델링의 예로 현실세계에 있는 자동차를 객체!! 우리가 사용하는 xcode에서 swift언어로 class안에 넣어서 표현해봅시다!!

차량 모델명, 출시일, 색, 속도, 연료량, 브랜드 등이 있습니다. 차를 보면 생각나는 명사들 이들은 자동차의 속성으로 표현될 수 있습니다(아닐수도있지만). 다시말해 자동차 클래스의 멤버변수(속성)으로 표현할 수 있습니다. 

 

클래스에는 멤버변수만 있는게 아닙니다. 멤버 함수도 포함됩니다. 속도를 예로 속도는 단순 "값"이 아니라 동작에 의해 증가 될수도, 감소 될수도 있습니다. 멤버변수인 '속도'의 값을 갱신하기 위해서는 자동차.속도 = 100으로 직접 클래스의 멤버변수에 접근해 값을 바꿀 수 있습니다. 그런데 캡슐화라는 개념이 있습니다.

 

 

OOP에서는 객체의 속성 상태와 동작을 캡슐화하는게 중요합니다. 그레서 일반적으로 객체의 속성은 접근 제어를 지정합니다. 외부에서 객체의 프로퍼티를 접근할 때는 직접 객체.프로퍼티로 접근하는 Read-only목적으로 읽을 수 있습니다. 근데 클래스 내 프로퍼티의 값 변경 할때는 직접 프로퍼티에 접근하는 방식보다 메서드를 통해서 접근하는게 일반적으로 좋습니다. OOP의 원칙중 하나인 캡슐화를 지키기 위함입니다.

중요!한게,, 클래스 내부 프로퍼티 값을 변경하기 전에는 '비교' 과정 등을 수행한 후에 값을 변경해야 합니다. Validation을 한다던가 등 값의 범위가 만족했을 때만 값을 새로 갱신한다던가!!!  이렇게 하기 위해서는 메서드 안에 위에서 언급한 로직들을 통해 갱신되야합니다.

 

그외에도 OOP하면 중요한 키워드가 Overriding, Overloading, class vs struct, composition(객체 안에 다른 객체 생성 선언하지 않고 함수의 매개변수로 받아오는것!!), polymorphism(다형성) 등의 개념이 있습니다. kodeco OOP를 보면서 개념 공부하면 좋습니다.

현대 자동차 소프티어 부트캠프를 하며.. | class vs struct

structclass의 차이는 무엇이고 언제 사용해야하는지에 대한 생각을 깊게 해보는 시간이었습니다. 

 

여러분도 무슨 차이가 있는지 잠시 생각해보세요..

 

왜 class를 쓰셨어요?, 왜 struct를 쓰셨어요? 라는 질문에 대해 (엏 클래스 쓰라고되어있는데)라는답을 고민을 충분히 하게 되는 시간이었습니다.

 

제가 생각하기에 class와 struct의 가장 큰 차이점은 OOP관점에서 상속 여부인 것 같습니다. 클래스는 상속이 되지만, Struct는 상속이 안되거든요. class는 heap영역에 저장되고, struct는 스택영역에 저장되지만 heap에 저장되기도 합니다. 그 이유는 요 글에 정리를 했습니다(cf. CoW: Copy-On-Write).

힙 영역에 할당되는 참조 객체들은 ARC가 동작되어 메모리가 처리됩니다. ARC에서 0이된다는 것은 더 이상 객체를 참조하지 않는다는 것이고 메모리에서 자동으로 해제됩니다. 반면 스택 영역에 할당되는 값 타입들은 ARC가 동작되지 않습니다. 그러나 CoW기법으로 메모리를 효율적으로 관리하기 위해 힙 영역을 통해 데이터가 공유될 수 있습니다.

 

 

부트캠프에서 자신이 구현한 코드에 대한 공유 시간이있었는데 " 왜 struct 쓰셨어요?" 라는 질문 등이 오가다가 어느 한 분이 말했던 말이 탁 와닿아서 정리를 해봤습니다. "A객체가 게임에 대한 주요 룰을 갖고있어! 그리고 이 정보는 B화면에서도 쓰이고 C화면에서도 쓰여. A객체를 B화면에서 생성했고 C화면으로 가야해. 근데 룰은 동일해야하고 여기에서 A객체를 전달해야한다면?"라는 내용과 비슷한 대화가 있었습니다.


제가 생각했을 때, 위 경우 구조체를 사용하게 된다면, 몇가지 속성이 변경되면 B화면에 있는 A객체에도 동일하게 적용되야 하는 상황이라면? struct 를 사용했을 경우 복사가 일어나 서로 다른 메모리 주소에 할당된 두개의 객체가 독립적으로 사용되겠네요. (아!! 물론 inout을 사용 할 수도 있겠네요. 그치만 지금 상황에선 제외..) 이런 경우에는 참조 타입인 class를 사용하는게 바람직하다는 것을 "아하"하면서 느꼈습니다. 반면 각각의 화면에서는 사용자가 서로 다른 룰을 만들어 가야한다면? struct를 사용한 값 복사도 좋을 것 같다는 생각이 들었습니다. 언제 무슨 객체를 쓰는지 다시 한 번 알게 되는 시간이었습니다..

 

struct를 사용한다는 것은 복사가된다. 사용자가 여러 화면에서 서로 다른 룰을 만들어갈 수 있다.

class를 사용한다는 것은 참조가 된다. 한 화면에서 변경된 룰이 다른 화면에서도 변경되어 작동된다

간단한 class 개념 설명

class Food { // 클래스
	init()
	....
}

 

class에 대해 간략하게 설명을 하자면 참조타입 입니다. 내가 작성한 class는 코드는 코드 영역에 정의됩니다. 아래의 코드!!

 

let ABC초코쿠키 = Food() // ABC초코쿠키 인스턴스

 

class의 init() 생성자를 통해 인스턴스는 힙 영역에 할당됩니다. 여기서 힙 영역에 인스턴스가 정의되었어도 내부적으로 포인터를 통해서 코드 영역에 있는 클래스의 정의를 참조하면서 사용됩니다. Swift도 unsafepointer 등 포인터를 다룰 수있는 함수들이 있습니다.(관련 링크) 각각의 인스턴스들은 힙 영역에 생성되어 자신만의 상태를 갖고 있으면서도 클래스의 정의를 공유합니다. 여러 개의 인스턴스는 코드영역의 내가 정의한 class 코드를 참조하기에, 클래스의 정의는 메모리에 한번 로드됩니다.(메모리 효율)

OOP관점 내가 설계한 클래스에서 보완해야 할  것들...

OOP를 기반으로 작성한 클래스와, PR의 코드리뷰를 통해 조금씩 개선하며 객체 지향을 설계한 코드를 공유하려고 합니다.

카드는 모양과 숫자가 있다. 앞면 뒷면 구별도 된다.

 

이 주제가 있습니다. 기존의 고민은 상속을 했을 때 parent의 멤버 변수들이 Int, Float, String등의 타입을 지정했다면, 해당 parent를 상속받은 derived 객체는 그 변수의 타입을 변경할 수 없습니다. 그 이유는? 상속을 받았기 때문에 부모로부터 강한 의존성을 가지고 있기 때문입니다. 메서드는 오버라이드를 하거나 새로 만들어도 되는데 .. 프로퍼티는? 새로운 타입을 선언해야 합니다.

 

포커 카드, 트럼프 카드 등 여러 객체가 공통적으로 가질 수 있는 것 + 자손객체에서 자신이 원하는 타입을 선언해 사용할 수 있도록 지정하고 싶었습니다. 지금 생각해보니 카드가 다 비슷하네요.. 그래도!! 자식 객체에서 자식이 정한 타입의 모양, number를 상속받도록 표현해보고 싶었습니다. Int타입이든, 로마 숫자 등 다양하게요.. 모양 또한 이미지 or 글자, 이모티콘 등 다양한 종류를 자식 객체에서 사용할 수 있도록 표현해보고 싶었습니다.

 

 

카드 클래스는 는 다양한 자식객체에서 상속받아 다양한 효과, 기능들이 구현될 것입니다. 그리고 카드는 deck에 저장되어 관리될 것입니다. PR의 소중한 코드 리뷰를 받았었는데 값 타입의 범위 제한도 중요하다고 느꼈습니다. Int는 워낙 범위가 크기 때문인데, 지금 당장 카드에 사용되는 범위는 엄청 작기 때문입니다.

 

 

맨 처음 카드는 모양과 숫자를 가지고있다.. 를 이렇게 표현해봤습니다. 제너릭을 통해 다양한 Hashable타입을 받을 수 있도록요. 그런데 여기서 중요한 것 은 다른 개발자가 봤을 때 이게 어떻게 사용될지 예측이 가능해야 한다는 것입니다. "왜 제너릭을 사용했나요?". 지금 다시보니 왜 Hashable이 들어갔지? 저는 저만 생각하고 있었습니다. (카드 타입에는 enum이 와야해!!! 딕셔너리에도 저장할 계획이었어요...)

 

그래서 구조체나 enum등 커스텀으로 타입을 정의할 수 있도록 protcool을 만들었습니다.

 

 

이것도 많이 부족하긴한데.. 원래는 서로 다른 enum 타입만 구체화 할 수 있도록 표현해야 했는데.. 프로토콜을 통해 이를 채택하는 것과 단순네이밍으로 구별했습니다.. enum에 대해 좀 더 공부를 해봐야겠습니다. 그래도 일단은 이 프로토콜을 상속받은 타입만 Card의 제너릭 타입으로 올 수 있습니다.

 

 

지금은 이렇게 저장을 했는데.. 카드 모양도 상황에 따라 다양한 종류가 게임 상황에서 추가될 것이라 생각을 해서 

 

 

모양 삭제나 추가 저장 공간을 만들었는데 띠로리.. 지금 다시 생각해보니 enum을 추가해야 Storage를 사용할 수 있었네요. 뭔가 코드가 대단히 잘못됬네요. 읏,,, (그래도 이걸 구현하면서 프로토콜이랑 제너릭이랑 친구가 되었습니다.)

 

 

다시 돌아와서 카드 구조체를 아래와 같이 변경했습니다.

 

class Card<Shape: CardShapeEnumProtocol, Number: CardNumberEnumProtocol> {
  enum Appearance {
    case front
    case rear
  }
  
  // MARK: - Properties
  private(set) var number: Number
  private(set) var shape: Shape
  private var _appearance: Appearance
  
  var appearance: Appearance {
    get {
      _appearance
    } set {
      _appearance = newValue
    }
  }
  
  // MARK: - Lifecycle
  init(number: Number, shape: Shape, appearance: Appearance) {
    self.number = number
    self.shape = shape
    self._appearance = appearance
  }
}

 

기본적으로 앞면, 뒷면, 모양, 카드 숫자가 있습니다.  이때 shape는 CardShapeEnumProtocol을 채택한 객체만 올 수 있습니다... 이렇게 카드 객체를 만들었는데요.

 

"나쁘지 않군!"이라 생각을 했는데. 피드백을 받고 또 생각하게 되었습니다..

 

1. private _var appearance: Appearnce | var appearance { get set }. 문제!

2. 메서드 없음. 문제!

 

 

좋은 점은 제너릭과 enum을 상속해서 자식 클래스를 구현한다면 런타임이 아닌 컴파일 단계(빌드하는 단계라 생각하면 편합니다)에서 개발자가 작성한 코드의 문제를 쉽게 확인할 수 있다는 점입니다.

 

더 개선할 수 있는 기능들

1. private var _appearance: Appearance | var appearance { get set } 문제!

지금 보이는 _appearance stored 프로퍼티는 접근 제어를 private와 var로 설정했습니다. appearance computed property는 private 접근 권한을 가진 _appearance (var: 변할 수 있는 프로퍼티)에 대해 외부에서 읽기와 쓰기를 할 수 있습니다.

 

이때 더 좋은 코드를 작성하는 방법이 있습니다. private 접근권한이 결국 제한된 의미에서 사용하는 것인데, appearance를 통해 외부에서 값 대입이 그대로 된다는 것입니다. 이무런 검증이나 유효성 검사가 없이 말입니다. 그래서 set에 유효성, 조건 검사 등의 로직을 수행하는 것이 더 바람직합니다. private 프로퍼티에 대한 set을 사용할 때 주의해야 합니다.

2. 메서드 없음. 문제!

포커, 바이시클 등 여러카드에서 공통적으로 수행될 수 있는 기능들이 있습니다. 카드끼리의 비교, 카드 정보 출력 카드 뒤집기는 appearance의 set으로 했는데 따로 조건 검사를 한 후 업데이트 하도록 하는 메서드로 만드는게 좋을 것 같습니다.

 

저 또한 이전부터 코드를 짤 때 클래스 내부변수는 전부 private으로 했지만, 값을 변경해야 할 경우, 값을 볼 경우 computed property를 사용해 {get set}을 그냥 추가하면서 "이럴거면 pulbic이랑 뭐가달라" 생각을 했었는데 조건 검사를 통해 이미 같은 경우 데이터를 변경히자 않거나, 조건 검사를 한 후 데이터를 업데이트 하는게 더 효율적이라는 것을 알게 되었습니다.

 

그래서 일단 이렇게 기능을 추가했는데요. enum의 RawValue를 통해 어느 카드가 큰지 비교하기 위해서 프로토콜에 뭐를 채택해야 할지

좀 더 공부봐야 겠습니다..