C언어

C++ 프로그래밍 - 표준 입출력, 이름공간(namespace), 인라인 함수(inline function), 함수 오버로딩(function overloading)

머리큰개발자 2021. 5. 4. 01:35
C의 진화

절차지향 언어인 C언어는 직관적이고 편리했다.

C언어는 함수 기반으로 작동하기 때문에, 필요할 때마다 함수를 작성하고 가져다 쓰기만 하면 되어서 

쉽고 빠르게 작성하는 것이 가능했다.

하지만 소프트웨어가 발달함에 따라서 거대한 프로젝트들이 등장하여, 빠르게 새로운 코드를 작성하기보다 기존 코드들을 유지, 보수하는 일이 중요해졌고, 전체적인 코드의 이식성과 유동성이 중요하게 되었다. 

예를 들어, 동일한 프로젝트에서 여러 사람이 동시에 개발할 때, 변수나 함수 이름이 겹치는 경우에 이름 재정의 오류가

발생하면서 프로그램 전반에 걸쳐서 이름을 수정하는 등의 문제가 발생했다.

 

이러한 변화에 따라 C언어도 변화가 필요했다. 

사실 잘 생각해보면 C언어를 조금 더 넓은 세상으로 확대시켰다고 생각할 수 있다.

조금 비유를 해보자면, C언어는 내가 우리집에서 쓸 수 있는 기계들을 코드로 만든다고 가정해보자.

창고에 청소기를 만들었다면, 그 청소기를 이용해서 집안 구석구석 돌아다니면서 사용(청소)할 수 있다.

마찬가지로 내 방에 침대를 만들었다면, (좀 힘들겠지만) 안방에 옮겨서도 쓸 수 있다. 

이 때, 슬리퍼와 같이 온갖 곳을 다 돌아다닐 수 있는 녀석도 있고(전역 변수), 내 방에 붙어 있는 벽지라던지

장판 등은 새로 깔지 않는 이상 다른 곳으로 들고 갈 수 없다.(지역 변수)

하지만, 남의 집에 가서 내가 쓰던 청소기를 쓰려니 그 쪽에서 이미 청소기를 구매해놨다면, 

굳이 내 청소기를 들고 가지 않아도 그 청소기를 이용하면 된다. 하지만 내 청소기와 똑같은 모델이라면

누가 누구 청소기인지 구별하기 힘들 수 있다!(재정의)

 

그렇기 때문에 조금 더 넓은 범위에서 C를 사용하고자 하면, 집집마다(namespace와 유사) 묶어서 그들이 쓰고 있는 것들(변수,함수)을 묶어서 관리하는 것이 굉장히 편리할 것이다. 

묶어서 관리한다면, 그 집에서 활동할 때는 그 집에서 제공되는 아이들만 쓰면 되기 때문에 편리하고 간단하다.

 

이렇게 집집마다 필요한 물건(변수)와 그 기능(함수)들을 묶어서 생각하는 것을 객체(object)라고 한다.

객체의 정의와 개념은 후술하도록 한다.

C++은 이 비슷한 컨셉을 통해 OOP(object oriented programmin)을 구현할 수 있고, 

C언어도 완벽하게 호환이 되기 때문에 둘 다 가능한 훌륭한 언어라고 할 수 있다. 

 

C언어를 기반으로 C++을 이용한다면 문법이 비슷하고 작동 원리가 비슷하므로 훨씬 쉬울 것이라 기대한다.

윤성우님의 열혈 C++ 프로그래밍 서적을 통해 공부했다.

표준 입출력

우선 기본적인 라이브러리가 변경되었다.

C언어를 할 때 표준 입출력을 지원하는 헤더는 <stdio.h> 였다. 

stdio를 통해 printf와 scanf 를 사용해 입출력을 했지만,

C++에서의 표준 입출력은 <iostream> 에 있다.

iostream의 std라는 namespace에 있는 std::cout 과 std::cin 을 사용하여 입출력을 수행한다.

 

#include <iostream>

int main(){
	int a;
    char name[100];
    std::cin >> a >> name;
    
    std::cout<<a<<" "<<name << "\n";
    
    return 0;
}

Visual Studio 최신 버젼에서는 main함수 마지막에 return 0 가 없어도 자동으로 채워주므로 쓸 필요는 없다.

변수의 선언은 C언어와 동일하게 수행하였다.

namespace std 얘기는 나중에 하도록 한다.

C++에서는 아주 편리하게 데이터의 타입을 신경쓰지 않고 입출력을 해도 된다. 알아서 받아들여준다.

또한 >> 연산자나 <<연산자는 자신의 참조값을 반환하므로 연속해서 대입해도 수행이 가능하다. 

참조값에 대해서는 후술한다.

 

C에서의 문법이 완벽하게 호환이 되기 때문에 C언어를 기반으로 프로그래밍을 했다면 무리 없이 모든 기능을

써서 프로그래밍하면 된다. (편하쥬?)

물론 동적 할당이나 함수 정의에 있어서 추가된 점이 존재하니까 염두에 두어야 한다.

 

우선 std를 사용하기 위해 이름 공간(namespace)에 대해서 먼저 살펴보자.

 

이름공간(namespace)

이름공간은 그 이름 그대로 이름을 붙여놓은 특정된 공간이다.

사실 위에서 비유한 집의 경우가 이름공간의 경우와 완전히 동일하다. 

서로 동일한 변수명이나 함수명을 사용할 경우 충돌이 발생하기 때문에 이름공간으로 분리하여 식별한다.

 

#include <iostream>

namespace space1{
	void print(){
    	std::cout<<"space 1 is printed\n";
    }
}

namespace space2{
	void print(){
    	std::cout<<"space 2 is printed\n";
    }
}

int main(){
    space1::print();
    space2::print();
    return 0;
}


        

만약 이름공간으로 분리하지 않았다면 print() 함수는 충돌이 일어났을 것이다.

하지만 C++에서는 완전히 분리된 것으로 간주하기 때문에 둘의 충돌을 방지할 수 있다.

이름공간에 접근하기 위해서는 범위지정 연산자(scope resolution operator) :: 을 사용한다.

 

또한 함수의 경우 이름공간 밖에서 정의를 해도 유효하다.

#include <iostream>

namespace space1 {
    void print() ;
}

namespace space2 {
    void print() ;
}

int main() {
    space1::print();
    space2::print();
    return 0;
}

void space1::print() {
	std::cout << "space 1 is printed\n";
}

void space2::print() {
	std::cout << "space 2 is printed\n";
}

이 방식은 후의 구조체(structure)나 클래스(class)에서도 유효하니 꼭 알아두자.

거의 대부분 이런 식으로 선언과 정의를 분류하기 때문이니 나중을 위해서라도 익숙해지자.

또한 같은 이름공간에 속한 함수끼리는 서로 ::연산자 없이 바로 호출해도 된다. 

 

이름공간은 또다른 이름공간을 포함할 수 있는데, 접근은 동일하게 ::를 연속으로 사용한다.

#include <iostream>

namespace space1 {
    void print();
    namespace space2 {
        void print();
    }
}


int main() {
    space1::print();
    space1::space2::print();
    return 0;
}

void space1::print() {
    std::cout << "space 1 is printed\n";
}

void space1::space2::print() {
    std::cout << "space 2 is printed\n";
}

::연산자가 정말 너무 귀찮다! 하면 using을 이용하면 된다.

#include <iostream>

namespace space1 {
    void print();
    namespace space2 {
        void print();
    }
}

using namespace space1;

int main() {
    print();
    space2::print();
    return 0;
}

void space1::print() {
    std::cout << "space 1 is printed\n";
}

void space1::space2::print() {
    std::cout << "space 2 is printed\n";
}

using 은 namespace를 전역변수처럼 전부 풀어버린다. 

풀어진 namespace는 이름공간의 지정 없이 그냥 접근해서 사용해도 된다. 

또한 개별적으로 풀어버리는 것도 가능하다. using space1::print; 를 사용할 경우 print()는 자동적으로 space1에서

찾아서 호출하게 된다.

또 space2의 print를 사용하려면 space1::space2::print()를 호출해야 하는데, 여간 번거로운 일이 아니다.

이럴 경우 별명을 붙일 수 있는데, namespace s12=space1::space2; 로 지정하여 s12::print()를 호출할 경우

space2의 print가 호출된다.

 

범위지정 연산자 ::는 또 전역변수에 접근할 수 있게 해주는 역할도 한다.

#include <iostream>

int a=10;
int main(){
	int a= 20;
    a+=10;
    std::cout << a<<"\n";
    std::cout <<::a<<"\n";
    return 0;
}

보면 지역변수 a에 의해 전역변수 a가 가려진 상황이다. 하지만 ::를 통해 a를 호출할 경우 전역변수가 호출된 것을

볼 수 있다.

왜 cin/cout을 사용할 때 std:: 를 붙이는지 이제는 알 수 있다. 

하지만 정말 귀찮은 일이므로 (안그러는게 좋지만) using을 사용하여 std의 제한을 풀고 사용하겠다.

C에서는 절대 불가능한 일이었는데 완전 대박!

 

인라인 함수(Inline function)

C에서 추가된 또다른 것으로 인라인 함수를 꼽을 수 있다.

코드 line 안 in으로 들어가는 함수를 의미한다. 

C에서는 이 함수를 주로 매크로 함수로 사용했었다.(define)

이는 전처리기에서 해당 매크로들을 직접 복사 붙여넣기 해주는 방식이었는데, 호출에 대한 오버헤드를 방지해주기 때문에 실행속도에 이점이 있었다. 

하지만 #define 을 사용해보신 분이라면 모두 알듯이, 괄호도 귀찮고 정의하기도 굉장히 어렵다. 특히 몇 줄씩만 길어져도 여러번 사용할 경우 코드의 양이 엄청나게 증가하고, 복잡한 함수는 정의자체가 어렵다.

만약 매크로 함수를 일반적인 함수처럼 정의하고 사용할 수 있다면 굉장히 편리할텐데, C++에서는 그게 가능하다! 여긴 마법세계라네~

 

#include <iostream>

#define SQR(x) ((x)*(x))

inline int SQUARE(int x) {
    return x * x;
}

int main() {
    std::cout << SQR(10) << "\n";
    std::cout << SQUARE(10) << "\n";

    return 0;

}

또 하나의 장점은, 매크로는 전처리기에 의해 바뀌기 때문에 무조건적으로 치환해주지만, 

inline 키워드(힌트)를 통한 인라인 함수는 컴파일러에 의해 치환되고, 컴파일러는 성능에 이점이 있을 때만 이 함수를 인라인화 시켜주기 때문에 최적화에 장점이 있다. 

물론 키워드(힌트)를 무조건적으로 따르지 않기 때문에 일반 함수들도 인라인 함수로 바꿨을 때 이득이 있다면 임의로 인라인화하기도 한다.

 

물론 인라인 함수와 매크로 함수의 차이가 존재한다.

당장 위 코드에서도, SQUARE는 integer타입만 가능하다. 

그에 반해 SQR은 자료형에 독립적이기 때문에 조금 더 우수하다고 볼 수 있다.

이런 단점을 극복하기 위해 함수에는 오버로딩(overloading)과 템플릿(template)을 제공한다.

 

오버로딩(overloading)

C에서는 동일한 이름의 함수가 2개 이상 존재하는 것은 무조건적으로 불가능했다.

하지만 C++에서는 동일한 이름을 사용하여 여러 다른 기능을 사용하고자 했기 때문에 함수 기능이 확장되었다.

 

위의 함수에서 같은 기능의 다른 자료형을 계산하기 원할 때를 생각해보자.

double SQUARE_DOUBLE(double x){return x*x;} 로 새롭게 이름을 주어 구현할 것인가?

C에서는 이런 방식으로 구현했었지만 C++에서는 더이상 이러지 않아도 된다.

 

C에서는 함수를 함수명으로만 구분했기 때문에 오버로딩이 절대 불가능했다.

C++에서 함수는 함수명 뿐 아니라 전달 인자(argument)로도 구분이 가능하다.

전달 인자의 타입과 개수가 다를 경우 컴파일러는 호출하고자 하는 함수를 찾을 수 없다.

여기서 반환타입은 전혀 상관이 없기 때문에 반환타입만 다른 함수는 동일하게 취급되어 충돌이 발생한다.

이 두 가지를 염두에 두면 다음은 불가능한 경우이다.

#include <iostream>


double SQUARE(int x) {
    return x * x;
}
int SQUARE(int x) {
    return x * x;
}

int main() {
    std::cout << SQUARE(10) << "\n";

    return 0;

}

VS2019 기준으로, "반환 형식으로만 구분되는 함수를 오버로드할 수 없습니다." 오류가 발생한다.

즉 오버로딩의 기준은 전달인자가 되어야 한다는 뜻이므로 다음과 같이 수정해보자.

#include <iostream>


double SQUARE(double x) {
    return x * x;
}
int SQUARE(int x) {
    return x * x;
}

int main() {
    std::cout << SQUARE(10) << "\n";

    return 0;

}

이제 잘 되는 것을 볼 수 있다. 거기에다가 SQURE(10.)을 넣어도 잘 동작하는 것을 확인할 수 있을 것이다.

오버로딩은 삶을 윤택하게 만들어줄 수 있는 강력한 도구이므로 필히 숙지해두자.(사실 숙지안하면 OOP 못함)

 

또한 C는 추가적으로 좋은 기능이 더 있다.

함수에서 사용할 매개변수(parameter)의 기본값(default value)을 설정해줄 수 있다는 것이다.

 

#include <iostream>


double SQUARE(double x) {
    return x * x;
}
int SQUARE(int x=7) {
    return x * x;
}

int main() {
    std::cout << SQUARE() << "\n";

    return 0;

}

이 결과를 예측할 수 있을까?

SQUARE()은 전달 인자가 아무것도 없기 때문에 C에서는 정의되지 않은 함수의 오류가 발생할 것이다.

하지만 실제로 수행해보면 49가 출력이 된다.

이는 int 타입의 함수에서 아무것도 전달이 되지 않으면 x는 7로 전달 받은 것으로 생각하겠다고 설정했기 때문이다.

이를 매개변수의 기본값이라고 생각하면 된다.

물론 double 에도 설정할 수 있지만, 두 개 다 설정해놓을 경우 전달 인자가 없을 때 호출할 함수가 모호해지므로 하나만 설정하여 명확하게 해주어야 한다.

 

디폴트값의 설정은 함수의 선언부에만 위치시켜야 하며(정의를 따로 할 경우), 인자가 여러개일 경우 오른쪽부터 빠짐없이 설정해주어야 한다.

만약 빠짐이 있다면 오류가 발생하고, 모두 설정해주지 않아도 된다. 

가령 다음과 같은 경우는 오류가 발생한다.

#include <iostream>


int ADD(int x, int y=9, int z) {
    return x * x;
}

int main() {
    std::cout << ADD(1,2,3) << "\n";

    return 0;

}

이는 z는 기본값이 설정되지 않았는데 y는 설정되었기 때문에 발생한 것이다. 

이 같은 설정은 전달 인자가 왼쪽에서부터 채워지기 때문인데, 위의 경우에서 ADD(1,2)를 할 경우 x=1,y=2가 채워지기 때문에 z는 초기화 되지 않고 사용되므로 문제가 발생하기 때문이다.

즉, 오른쪽부터 채워야 전달인자가 꽉 채워질 수 있기 때문에 설정해놓은 것이니 너무 불만을 갖지 말고 사용하도록 하자.