본문 바로가기
공부/Programming Language

C++ 4일차

by Piva 2021. 5. 7.

  하루 공부 양이 뒤죽박죽이라 어느 정도 내용이 쌓이면 적으려고 하고 있다. 

(※ 개인 정리를 위한 것이므로, 이해한 내용 생략다수 + 불친절함 주의)

 

 


explicit

  'explicit' 이란 '분명한, 명쾌한' 등의 의미를 갖는 단어이다. 이 친구가 하는 역할을 정리하려면, '명시적 변환'과 '암시적 변환'에 대해 대강이나마 정리할 필요가 있다. 

 

  C++의 컴파일러는 똑똑하다. 그렇기 때문에 프로그래머가 짠 내용을 참고해서 자기 나름대로 생각하고 사용자가 편하도록 알아서 처리할 때가 있다. 변환 또한 그러하다. 

 

class CustomString
{
	private:
    	char * str;
    public:
    	CustomString(const char* string);
        /*...*/
};

/* ... */

void foo(CustomString str)
{
	/* ... */
}

int main ()
{
	foo("a");
}

  대충 이런 코드가 있다고 치자. 사용자가 정의한 CustomString 타입 변수를 인자로 갖는 foo함수가 있고, main 함수에서 해당 foo함수를 호출하고 있다. 다만, CustomString 타입이 아닌 그냥 string타입 변수를 전달하고 있음을 확인할 수 있다. CustomString 함수는 그냥 string타입 인자를 갖는 함수가 오버로딩 되어있는 것도 아니기 때문에 실행이 되지 않을까 싶지만, 이 코드는 오류 없이 실행이 잘 될 것이다. 이유는 C++ 컴파일러의 똑똑함에 있다.

 

  CustomString 클래스의 정의를 살펴보면, CustomString 클래스의 생성자가 const char * 타입을 인자로 받는다는 것을 확인할 수 있다. 컴파일러는 이를 보고 foo가 인자로 받은 "a"를 CustomString 클래스의 인스턴스로 알아서 변환해 foo를 실행하게 되는 것이다. 이를 '암시적 변환'(프로그래머가 변환을 임의로 명령한 게 아니므로)이라고 부른다.

 

  위와 같은 경우에서는 암시적 변환이 분명히 유용하나, 언제나 암시적 변환이 유용하다고는 할 수 없다. 자동적인 변환이 전혀 필요하지 않은 상황에서(원래라면 오류가 일어나야 하는 상황에서) 컴파일러가 알아서 변환을 하게 되면 의도한 결과가 나오지 않을 것이기 때문이다. 따라서, 암시적 변환을 막기 위해 우리는 explicit 키워드를 사용한다.

 

class CustomString
{
	private:
    	char * str;
    public:
    	CustomString(const char* string);
        explicit CustomString(int num);
        /*...*/
};

/* ... */

void foo(CustomString str)
{
	/* ... */
}

int main ()
{
	foo(10);
}

  다음과 같은 상황을 봐보면, int 형 인자를 받는 생성자에 explicit 키워드가 적용된 것을 확인할 수 있으며, main함수에서는 foo함수에 정수를 넣어 호출하고 있다. 현재 int형을 인자로 받는 CustomString의 생성자는 explicit 키워드가 적용되어 있기에 오직 '명시적 변환'만을 허용하게 되며, 이 때문에 암시적 변환을 시도하게 되는 foo함수의 호출은 막혀 오류가 발생하게 될 것이다.

 

  이러한 explicit 키워드는 일반적인 생성자 뿐이 아닌 복사 생성자의 호출 또한 막을 수 있다. 위의 클래스 예시를 사용하자면

CustomString a(10);
CustomString b = 10; //X

  요게 안 된다는 것이다. 자칫 프로그래머가 낼지도 모를 실수를 방지해 줄 수 있다는 점에서 유용할 듯 싶다.

 

 

mutable

  mutable은 무슨 의미인가. 찾아봐도 못 외울 확률이 높지만 일단은 '변할 수 있는' 이라는 의미라고 한다. '변하다'라는 단어를 보면 생각날법한 키워드가 있다. 저번에 언급했던 const의 특징이 어떠했던가. 변경할 계획이 없는 변수, 혹은 값을 읽기만 하는(즉, 변경하지 않는) 함수에 대해 const를 붙이면 좋다고 언급한 적이 있었었다. 단어의 의미만 보자면 mutable은 무언가 const의 반대 역할을 할 것처럼 보인다. 그리고 얼추 들어 맞는다.

 

  mutable의 정확한 역할은 이러하다. 특정 변수를 mutable로 선언하면, const 함수에서도 이들의 값을 변경할 수 있다는 것이다.

class Test
{
	private:
    	int num;
    public:
    	void ChangeNum(int x) const;
};

//잘 못된 함수
void Test::ChangeNum(int x) const
{
	num = x;
}

  위의 예시 코드에서 ChangeNum 함수는 사실상 잘 못된 함수인데, const 함수이면서도 클래스의 멤버 변수를 바꾸려는 시도를 하고 있기 때문이다. 이 경우 ChangeNum 함수를 사용하면 오류가 날 것은 명백한 사실이다. 다만, mutable을 사용하여 아래와 같이 쓰면 오류없이 사용하는 것이 가능하다.

 

class Test
{
	private:
    	mutable int num;
    public:
    	void ChangeNum(int x) const;
};

//이제 사용 가능
void Test::ChangeNum(int x) const
{
	num = x;
}

  얼핏 보면 mutable을 굳이 사용하지 않고 const를 함수 선언에서 제거하는 것이 낫지 않나 하는 마음이 드는 것도 사실이나, 프로그래밍 언어 또한 '언어'의 기능을 한다는 사실을 다시금 떠올리면 마냥 그렇지만은 않다는 것을 알 수 있다. const는, '이 함수가 클래스의 멤버 변수를 바꾸지 않는다' 라는 의미를 내포하고 있다. 즉, 해당 함수의 역할을 const라는 키워드 하나로 알려줄 수 있다는 것이다. 함수가 수행하는 기능에 따라서 const 함수로 선언되었더라도 특정 값을 바꿔야만 하는 경우가 생길 수 있으니, mutable을 사용하여 그런 경우에 대응할 수 있도록 만든 것이라고 생각할 수 있겠다.

 

 

연산자 오버로딩

  여태까지는 그냥 함수에 대한 오버로딩을 다루었었다. 그러나 여기에서 끝나지 않고, C++에서는 특이하게도 연산자에 대한 오버로딩을 지원한다. 따라서 이를 잘만 응용하면, 자신이 만든 임의의 클래스에 대해서도 연산자를 통한 연산을 수행할 수 있게 될 것이다. 이렇게 오버로딩이 가능한 연산자는 사칙연산 뿐 아니라 비교 연산자, 논리 연산자, 증감 연산자 등 그 종류도 다양하다.

 

  연산자를 오버로딩 하는 것은 간단하다. 먼저, 다음의 형식을 따른다.

[리턴 타입] operator [오버로딩 할 연산자] [인자]

  이를 실제로 적용해보기 위해, 분수를 나타내는 클래스를 새로이 만들었다(자료에서는 복소수를 나타내는 클래스를 작성하나 연습 겸 만들어 보았다). 분수를 구성하는 분모와 분수를 멤버 변수로 갖고, 두 분수에 대한 사칙연산을 수행할 수 있도록 만들기 위해 +, -, *, /의 연산자들을 오버로딩 하였다. 약분 등의 구현은 생략한다.

 

#include <iostream>
#include <string.h>

class Fraction
{
  private:
    int denominator; //분모
    int numerator; //분자

  public:
    Fraction(int d, int n);

    Fraction operator+(const Fraction& f);
    Fraction operator-(const Fraction& f);
    Fraction operator*(const Fraction& f);
    Fraction operator/(const Fraction& f);

    void println();
};

Fraction::Fraction(int d, int n)
{
  denominator = d;
  numerator = n;
}

Fraction Fraction::operator+(const Fraction& f)
{
  if (denominator == f.denominator)
  {
    return Fraction(denominator, numerator + f.numerator);
  }
  else
  {
    return Fraction(denominator * f.denominator, numerator * f.denominator + f.numerator * denominator);
  }
}

Fraction Fraction::operator-(const Fraction& f)
{
  if (denominator == f.denominator)
  {
    return Fraction(denominator, numerator - f.numerator);
  }
  else
  {
    return Fraction(denominator * f.denominator, numerator * f.denominator - f.numerator * denominator);
  }
}

Fraction Fraction::operator*(const Fraction& f)
{
  return Fraction(denominator * f.denominator, numerator * f.numerator);
}

Fraction Fraction::operator/(const Fraction& f)
{
  return Fraction(denominator * f.numerator, f.denominator * numerator);
}

void Fraction::println()
{
  std::cout << numerator << "/" << denominator << std::endl;
}

int main()
{
  Fraction f1(2, 1);
  Fraction f2(3, 1);

  Fraction f3 = f1 + f2;
  f3.println();
}

  이를 실제로 컴파일 해보면 5/6으로 제대로 된 연산 결과가 나오는 것을 알 수 있다. 모든 오버로딩된 함수들은 인자로 받는 분수 f의 값을 바꾸지 않으므로 const로 인자를 받게끔 하였다.

 

  여기에 추가로 대입 연산자(=)와 대입 사칙연산(+=, -=) 등을 구현할 수도 있다. 그 결과는 아래와 같다.

#include <iostream>
#include <string.h>

class Fraction
{
  private:
    int denominator; //분모
    int numerator; //분자

  public:
    Fraction(int d, int n);

    Fraction operator+(const Fraction& f);
    Fraction operator-(const Fraction& f);
    Fraction operator*(const Fraction& f);
    Fraction operator/(const Fraction& f);

    Fraction operator=(const Fraction& f);

    Fraction operator+=(const Fraction& f);
    Fraction operator-=(const Fraction& f);
    Fraction operator*=(const Fraction& f);
    Fraction operator/=(const Fraction& f);

    void println();
};

Fraction::Fraction(int d, int n)
{
  denominator = d;
  numerator = n;
}

Fraction Fraction::operator+(const Fraction& f)
{
  if (denominator == f.denominator)
  {
    return Fraction(denominator, numerator + f.numerator);
  }
  else
  {
    return Fraction(denominator * f.denominator, numerator * f.denominator + f.numerator * denominator);
  }
}

Fraction Fraction::operator-(const Fraction& f)
{
  if (denominator == f.denominator)
  {
    return Fraction(denominator, numerator - f.numerator);
  }
  else
  {
    return Fraction(denominator * f.denominator, numerator * f.denominator - f.numerator * denominator);
  }
}

Fraction Fraction::operator*(const Fraction& f)
{
  return Fraction(denominator * f.denominator, numerator * f.numerator);
}

Fraction Fraction::operator/(const Fraction& f)
{
  return Fraction(denominator * f.numerator, f.denominator * numerator);
}

Fraction Fraction::operator=(const Fraction& f)
{
  denominator = f.denominator;
  numerator = f.numerator;

  return *this;
}

Fraction Fraction::operator+=(const Fraction& f)
{
  (*this) = (*this) + f;
  return *this;
}
    
Fraction Fraction::operator-=(const Fraction& f)
{
  (*this) = (*this) - f;
  return *this;
}

Fraction Fraction::operator*=(const Fraction& f)
{
  (*this) = (*this) * f;
  return *this;
}

Fraction Fraction::operator/=(const Fraction& f)
{
  (*this) = (*this) / f;
  return *this;
}

void Fraction::println()
{
  std::cout << numerator << "/" << denominator << std::endl;
}

int main()
{
  Fraction f1(2, 1);
  Fraction f2(3, 1);

  f1 += f2;

  f1.println();
}

  기존에 미리 구현해놓은 사칙연산 오버로딩 함수가 있기 때문에 이를 이용하면 쉽게 대입 사칙연산 함수를 구현할 수 있다. 

 

  입출력 연산자(<<, >>)도 오버로딩이 가능하다. 사실, C++에서 우리가 그동안 써오던 출력문 'std::cout << ~'에는 std::cout 객체에 멤버 함수 operator<<가 정의되어 있는 형태였었다고 한다. 즉, 우리는 모르지만 입출력을 위해 include해서 사용해오면 iostream에는 입출력 연산자에 대한 많은 오버로딩이 존재한다.

 

  그렇다면 위의 분수 클래스에 맞추어서 어떻게 입출력 연산자를 오버로딩 할 수 있나. operator<<에 대한 내용은 iostream, 정확히는 iostream이 include하고 있는 ostream에 정의되어 있다고 한다. 그렇다면 ostream 클래스에 직접 오버로딩을 작성하느냐, 이건 기존 헤더파일을 수정하는 행위이기 때문에 불가능하다. 따라서 다른 방법을 사용해야 하는데, 여기에는 미리 알고 가야하는 또다른 개념이 필요하다.

 

 

friend

  friend는 사전을 보나마나 '친구'라는 의미임을 바로 알 수 있다. 의미만 보아서는 어디에 쓰이는 것인지 별로 짐작이 가지 않는데, friend는 굉장히 특이한 역할을 한다. 

class A
{
	private:
    	int numA;
        friend B;
};

class B
{
	private:
    	int numB;
};

  A라는 클래스에서 B를 friend로 지정하고 있는 것을 확인할 수 있다. 이것의 의미는, 'B에게 A가 자신의 모든 것을 공개한다'라는 의미로, B 클래스에서는 설령 A에서 private으로 지정된 것이라 해도 접근이 가능하게 된다. 다만, 이 관계는 A가 B를 일방적으로 친구라고 생각하는 상황이므로, 그 반대(B가 A에 모든 것을 공개함)는 성립되지 않는다.

 

 

입출력 연산자의 오버로딩

  앞서 언급한 friend를 이용하면 입출력 연산자 또한 오버로딩 하는 것이 가능하다. operator<<를 friend로 설정하는 것이다.

#include <iostream>
#include <string.h>

class Fraction
{
  private:
    int denominator; //분모
    int numerator; //분자

  public:
    Fraction(int d, int n);

    Fraction operator+(const Fraction& f);
    Fraction operator-(const Fraction& f);
    Fraction operator*(const Fraction& f);
    Fraction operator/(const Fraction& f);

    Fraction operator=(const Fraction& f);

    friend std::ostream& operator<<(std::ostream& os, const Fraction& f);

    Fraction operator+=(const Fraction& f);
    Fraction operator-=(const Fraction& f);
    Fraction operator*=(const Fraction& f);
    Fraction operator/=(const Fraction& f);

    void println();
};

Fraction::Fraction(int d, int n)
{
  denominator = d;
  numerator = n;
}

Fraction Fraction::operator+(const Fraction& f)
{
  if (denominator == f.denominator)
  {
    return Fraction(denominator, numerator + f.numerator);
  }
  else
  {
    return Fraction(denominator * f.denominator, numerator * f.denominator + f.numerator * denominator);
  }
}

Fraction Fraction::operator-(const Fraction& f)
{
  if (denominator == f.denominator)
  {
    return Fraction(denominator, numerator - f.numerator);
  }
  else
  {
    return Fraction(denominator * f.denominator, numerator * f.denominator - f.numerator * denominator);
  }
}

Fraction Fraction::operator*(const Fraction& f)
{
  return Fraction(denominator * f.denominator, numerator * f.numerator);
}

Fraction Fraction::operator/(const Fraction& f)
{
  return Fraction(denominator * f.numerator, f.denominator * numerator);
}

Fraction Fraction::operator=(const Fraction& f)
{
  denominator = f.denominator;
  numerator = f.numerator;

  return *this;
}

Fraction Fraction::operator+=(const Fraction& f)
{
  (*this) = (*this) + f;
  return *this;
}
    
Fraction Fraction::operator-=(const Fraction& f)
{
  (*this) = (*this) - f;
  return *this;
}

Fraction Fraction::operator*=(const Fraction& f)
{
  (*this) = (*this) * f;
  return *this;
}

Fraction Fraction::operator/=(const Fraction& f)
{
  (*this) = (*this) / f;
  return *this;
}

std::ostream& operator<<(std::ostream& os, const Fraction& f)
{
  os << f.numerator << " / " << f.denominator;
  return os;
}

void Fraction::println()
{
  std::cout << numerator << "/" << denominator << std::endl;
}

int main()
{
  Fraction f1(2, 1);
  Fraction f2(3, 1);

  f1 += f2;

  std::cout << f1 << std::endl;
}

  os가 인자에 들어간 이유는, std::cout이 iostream에서 만들어 놓은 ostream 객체이기 때문이다.

'공부 > Programming Language' 카테고리의 다른 글

C++ STL - 시퀀스 컨테이너  (0) 2021.06.04
C++ 5일차  (0) 2021.05.13
C++ 3일차  (1) 2021.05.03
C++ 2일차  (0) 2021.04.30
C++ 1일차  (0) 2021.04.28