stack을 만들면서 객체지향에 대해 알아보자 😁

안녕하세요! 두두코딩 널두 🥸 입니다 ✋
오늘은 객체지향 프로그램을 만들어보겠습니다!

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

Intro.

이번 포스팅에서는 C++ 객체지향 개념을 알아보기 위한 Stack with OOP #1을 알아보겠습니다. C++언어의 특징을 활용해 Stack을 만들어볼 것이며, 포스팅이 길어질 수 있어 나눠 기록하고자 합니다.

새롭게 등장하는 keyword는 뒷 포스팅에서 자세하게 다룰 것이라, Stack을 만들어보는 포스팅에서는 “아! 이런것도 있구나..” 하는 마음가짐으로 접근하면 좋을 것 같습니다!

Stack 프로그램 만들기 [1]

우선, Stack이라는 개념을 알아야한다.

간단히 설명하면, Stack은 LIFO (Last in First Out) 마지막에 넣은 값을 가장 처음 꺼내줄 수 있는 자료구조를 말한다. 해당 문장만으로 Stack을 이해하기 어려운 분들은 구글링해 찾아보면 좋을 것 같다.

#include <iostream>

// stack이라는 데이터 자료구조에 데이터를 넣을 수 있는 push(),
// 출력할 수 있는 pop()가 필요함.

int main()
{
  push(10);
  push(20);
  push(30);

  // 가장 마지막 값인 30이 출려되어야한다.
  std::cout << pop() << std::endl;
}

가장 쉽게 생각해 Stack을 만들 수 있는 방법은 무엇일까?

바로 배열을 활용해 구현하는 것이다. 전역에 배열을 하나 만들고, index 값을 두어 Stack 자료구조를 만들어보자.

#include <iostream>

int buf[10];
int idx = 0;

// 넣고 idx를 증가시켜야되기 때문에 후위 연산
void push(int n) { buf[idx++] = n; }

// idx는 값을 가리키고 있는 것이아니라, 넣어야될 부분을 가리키고 있기 때문에
// idx 포인터를 내리고, 출력할 값을 return해야함. 즉, 전위 연산 필요.
int pop()        { return buf[--idx]; }

int main()
{
  push(10);
  push(20);
  push(30);

  std::cout << pop() << std::endl;
}

위의 코드와 같이 전역에 배열을 만들고, Stack에 필요한 index를 활용해 관리하게 될 경우 Stack에 데이터 입/출력을 쉽게 할 수 있다.

하지만 여기서 생각해보자.

Stack을 2개 만들고자 한다면 어떻게 될까?

지금은 전역영역에 Stack을 하나 만들어 관리했다. 2개가 될 경우 하나 더 만들어 사용하면 된다. 예를들어 10개의 Stack을 사용할경우에는…

특히, push(), pop()에게 어떤 Stack인지와 index가 누구것인지를 같이 전달해야된다. 아래와 같이 인자 값이 늘어나 더 복잡해질 것이다.

// 아래와 같이 push와 pop이 전역의 stack이 누구것인지 알아야하기때문에 복잡해짐.

void push(int* s, int* idx, int n) { s->buf[*idx++] = n; }
int pop(int* s)        { return s->buf[--(*idx)]; }

위와 같이 전역으로 모든걸 해결하려고 할 경우 복잡해지고, 코드가 길어지는 문제가 있다. 이를 좀 더 개선해보자.

Stack 프로그램 만들기 [2]

우리는 이전 포스팅을 통해 OOP의 가장 먼저 할 일은 “필요한 타입을 먼저 설정하자” 라는 것을 배웠다. 앞서 배운 예시는 2개의 int 타입을 묶어 복소수를 표현하는 struct Complex 새로운 타입을 만들었다.

Stack도 배열과 인덱싱 포인트를 한데 묶어 자료구조화하면 관리가 더 편리하다.

#include <iostream>

struck Stack{
  int buf[10];
  int idx = 0;
};

void push(Stack* s, int n) { s->buf[s->idx++] = n; }
int pop(Stack* s) { return s->buf[--(s->idx)]; }

int main() {
  Stack s1, s2;
  s1.idx = 0;
  s2.idx = 0;

  push(&s1,10);
  push(&s1,20);
  push(&s1,30);

  // 30 출력 (가장 마지막 데이터)
  std::cout << pop(&s1) << std::endl;
}

위의 코드와 같이, struct Stack을 활용하면 다수의 Stack을 만들 때 좀 더 관리가 편하다.

혹시, 위 코드에서 pushpop의 내용이 좀 복잡하다고 느껴지지 않는가?

우선, 인자로 Stack의 정보를 받아야하고, 해당 Stack 내 데이터에 접근하기 위해 -> 연산을 활용해야된다는 점이 복잡하기도 하고, 코드상 보기도 별로이다.

복잡한 코드가 나온 원인을 먼저 분석 해보자.

Stack에 대한 정보를 넘겨야되는 이유는 우리가 struct 구조체를 만들 때 오직 데이터타입만을 묶어 만들었기 때문이다. 구조체 내 데이터에 접근해서 변경하는 동작같은 경우 데이터 값에 매우 의존적이다. 따라서, 묶어서 구조체를 만들 경우, 고유의 데이터에 접근할 때 추가적인 정보가 필요없게 된다.

“아.. 잘 모르겠고, 좀 더 단순화 해보자.”

우리는 단순화를 위해 C++의 Struct 특징을 활용해보고자 한다.

C의 구조체 struct 같은경우 오직 데이터만 묶어서 하나의 타입으로 만들 수 있었다. 하지만 C++의 구조체 struct 같은 경우 데이터 뿐만아니라 데이터와 관련된 함수도 묶어서 하나의 구조체를 만들 수 있다.

Stack 프로그램 만들기 [3]

C++ 구조체를 활용해, 데이터와 함수를 묶어 하나의 구조체로 만들어보자.

#include <iostream>

struct Stack{
  // 새로운 타입 내에 데이터가 있다고 해서 => 멤버 데이터
  int buf[10];
  int idx = 0;


  // 새로운 타입 내에 함수가 있다고 해서 => 멤버 함수

  // 데이터에 접근이 가능하기 때문에, Stack에 대한 정보를 받을 필요가 없음.
  // 함수가 훨씬 간결해짐.
  void push(int n) { buf[idx++] = n; }
  int pop() { return buf[--idx]; }
};

// 함수를 따로 관리할 땐 복잡함..
// void push(Stack* s, int n) { s->buf[s->idx++] = n; }
// int pop(Stack* s) { return s->buf[--(s->idx)]; }

int main() {
  Stack s1, s2;
  s1.idx = 0;
  s2.idx = 0;

  // 아래와 같이 Stack정보 넘길 필요 없음.
  // push(&s1,10);

  // s1 Stack에 20 넣어줘~ 좀 더 고차원적 의미.
  s1.push(20);

  // 인간 친화적으로 쓰여졌지만, 결국 컴파일러가 아래와 같이 변경함.
  // push(&s2,10); <- 컴파일러는 아래의 문장을 이렇게 변경함.
  s2.push(30);

  // 20 출력, s1의 마지막에 입력된 값.
  std::cout << s1.pop() << std::endl;
}

struct를 활용해 데이터와 함수를 묶어 사용하다보니, 함수 사용자체가 좀 더 인간 친화적(?)이 됐고, 간결해졌다.

위의 주석에도 적어뒀지만, 결국 컴파일러는 push(&s1,20)와 같은 형태로 변경해서 넘긴다는 것을 기억해두자.

추가로, 멤버함수에서는 멤버데이터에 접근을 마음대로 할 수 있다는 점을 꼭 기억하자!

우리는 [1],[2],[3] 의 방법을 통해 Stack을 좀 더 간결하고 편하게 사용하도록 변경해왔다.

[3] 코드가 완벽한 Stack일까?

그렇지 않다. 어떤 문제가 있을까? 아래의 코드를 보자.

int main() {
  Stack s3;

  // idx는 0부터 시작해야되는데.. 배열에는 -1이 없어ㅠ..
  s3.idx = -1;
}

위의 코드와 같이 만약 사용자가 idx 값에 잘못된 값을 입력한다면 해당 프로그램은 어떻게 될까?

실행하다 죽는다 물론 사용자가 프로그램을 잘 쓰면 된다. 하지만 개발자의 입장에서 잘못 사용하기 어렵게 만들 순 없을까?를 고민하는게 맞다. 좀 더 개선된 프로그램을 통해 사용자가 잘못사용하지 못하도록 만들어보자.

Stack 프로그램 만들기 [4]

프로그램은 잘못 사용하지 않도록 만드는 것이 좋다 위의 코드와 달리, 사용자가 Stack을 잘못 사용하기 어렵게 만들어보자.

C++에는 접근지정자 개념이 존재한다.

private 접근지정자를 사용하게 될 경우, 외부에서는 접근할 수 없고, struct or class 내부에서 접근이 가능하도록 한다.

public 접근지정자를 사용하게 될 경우, 외부에서 접근이 가능하다. 즉, 누구나 접근이 가능하다.

private같은 경우 멤버데이터 public 같은 경우 멤버함수를 지정할 때 많이 사용한다.

접근지정자를 활용해 멤버데이터를 사용자가 직접 접근하지 못하도록 만들어보자.

#include <iostream>

struct Stack{
private:
  int buf[10];
  int idx = 0;

public:
  // 초기화하는 함수.
  void init() { idx = 0; }
  void push(int n) { buf[idx++] = n; }
  int pop() { return buf[--idx]; }
};

int main() {
  Stack s1, s2;

  // 데이터에 직접 접근하는 것이 아닌, 멤버함수로 접근해 데이터를 변경하도록함.
  s1.init();
  s2.init();

  s1.push(20);
  s2.push(30);

  std::cout << s1.pop() << std::endl;
}

위의 코드와 같이 init 함수를 사용해 멤버데이터에 접근하는걸 막을 경우 외부에서는 멤버데이터 변경이 불가능하다. 멤버함수 내 예외처리를 두어 사용자가 잘못접근하는 것도 막을 수 있다는 장점이있다.

우리가 OOP를 할 때 가장 많이 듣는 가장 중요한 특징이 바로 캡슐화 💊 개념이다. “멤버 데이터를 직접적으로 접근하는 것은 불가능, 오직 멤버함수로만 접근해야됨.” 즉, 내부로 들어오려면 외부를 통해서 들어오도록 캡슐처럼 꽁꽁 싸맸다 라는 의미이다.

기억하자 캡슐화 (Encapsulation) 개념을..!!

추가로, class 개념을 설명하면 좋을 것 같다.

현재 코드에서 C++의 struct를 활용하고 있는데, 우리가 OOP를 하면 class 개념을 많이 듣곤한다. C++의 struct는 class 와 한가지만 제외하고 기능이 동일하다.

둘의 차이점은 default 접근 지정자의 차이다.

🌱 strcut - default 접근지정자는 public이다.

🌱 class - defautl 접근지정자는 class이다.

struct는 일단 모든 것을 열어두고 필요한 것만 막자 라는 개념이고, OOP에서 탄생한 class 개념은 캡슐화를 엄청 중요하게 생각해서 일단 다 막고 필요한 것만 열자의 개념이다.

이 코드에서는 수정할 부분이 없을까?

만약, 사용자가 초기화를 깜빡하고 init()를 호출하지 않고 Stack을 사용하고자 한다면 어떻게 될까?

초기화가 되지 않아 사용이 불가능할 것이다.

자동으로 초기화하면 좀 더 편하지 않을까? 개선된 프로그램을 통해 자동 초기화하는 방법을 배워보자.

Stack 프로그램 만들기 [5]

자동으로 초기화 하는 방법 생성자를 활용하는 방법이다.

사용방법은 아래의 코드와 같다.

#include <iostream>

struct Stack
{
private:
  int buf[10];
  int idx;

public:

  // 클래스 이름과 동일한 함수 : 생성자.
  // 만드는 사람보다.. 사용자에게 편하게 해줌.
  Stack() { idx = 0; }
  void push(int n) { buf[idx++] = n; }
  int pop() { return buf[--idx]; }
};

int main()
{
  Stack s1;
  // 만드는 순간 초기화.
  // s1.init();

  s1.push(10);
  s1.push(20);

  std::cout << s1.pop() << std::endl;
}

struct 또는 class를 활용한 사용자 타입의 생성을 변수 생성이 아닌 객체 생성이라 부른다.

객체를 생성할 때, 생성자가 불린다. 생성자는 자동적으로 불리도록 되어져있기 때문에 해당 영역에서 초기화를 하면 자동으로 초기화를 누릴 수 있다.

생성자의 몇 가지 특징

  1. 클래스 이름과 동일한 이름을 가지는 함수
  2. 객체를 만들면 자동으로 호출됨
  3. 리턴타입을 표기하지 않아도 됨
  4. 인자가 있어도 되고 없어도 됨

생성자를 활용하면 만드는 사람보다, 사용자를 좀 더 편리하게 만들어주는 장점이 있다.

Stack을 개선하면서 열심히 만들었는데, 이게 끝은 아니다. 내용이 더 있으며 너무 길어진 것 같아 다음 포스팅을 통해 추가로 개선하는 과정을 설명한다.

Outro.

해당 포스팅은 Ecourse의 C++ Basic 강의를 참고해 작성되었습니다.

강의를 참고하실 분은 여기를 클릭해 확인해주세요!