8. Technicalities: Functions, etc.

8.1. Technicalities

7장까지는 프로그래밍 언어의 특성보다는 프로그래밍 그 자체에 집중했지만, 프로그래밍 언어의 특성을 아는 것도 중요하다. 그러나 프로그래머는 프로그램이나 시스템을 만드는 사람이고, 프로그래밍 언어는 도구일 뿐이라는 사실을 잊지 말자.

많은 설계와 프로그래밍 개념은 보편적이기 때문에 한 프로그래밍 언어의 개념이 다른 언어에서도 쓰일 수 있다.

8.2. Declarations and defintions

선언문은 이름들을 범위 안에 선언함으로써 그 이름의 타입을 결정하고 경우에 따라 초기화자(초기값)도 결정한다. 다음은 선언문이다.

int a = 7; // an int variable
const double cd = 8.7; // a double-precision floating-point constant
double sqrt(double); // a function taking a double argument and returning a double result
std::vector<Token> v; // a vector-of-Tokens variable

C++ 프로그램에서는 이름이 사용되기 전에 선언되어야 한다.

선언된 것을 명시하는 선언문은 정의문이라 한다. 모든 정의문은 선언문이다. 선언문 중 정의문이 아닌 예는 다음과 같다.

double sqrt(double); // no function body here
extern int a; // "extern plus no initializer" means "not definition"

같은 이름의 정의문을 두 번 쓸 수는 없다. 선언문은 여러 번 가능하지만, 선언문끼리 충돌이 나면 안 된다.

int x = 7; // definition
extern int x; // declaration
extern int x; // another declaration

double sqrt(double); // declaration
double sqrt(double d) { /* ... */ } // definition
double sqrt(double); // another declaration of sqrt
double sqrt(double); // yet another declaration of sqrt

int sqrt(double); // error: inconsistent declarations of sqrt

선언문에서의 extern은 이 변수가 외부에서 정의된 것임을 알려 준다. 전역 변수를 남발하는 코드에서 많이 보이지만, 유용할 떄가 별로 없으므로 쓰지 않는 것을 추천한다.

C++은 왜 선언문과 정의문을 동시에 제공하는가? 이는 인터페이스와 구현을 분리하기 위함이다. 정의문은 메모리를 차지하지만 선언문은 메모리를 차지하지 않는다.

함수나 변수를 쓰고 싶을 때에는 그 선언이 호출하는 부분보다 앞에(위쪽에) 있어야 한다. 정의부가 호출부 아래에 있으면 선언문을 위에 두는 전방 선언을 하기도 한다.

8.2.1. Kinds of declarations

정의문의 종류는 다음과 같다.

  • 변수
  • 상수
  • 함수
  • 이름공간
  • 자료형 (클래스와 열거형)
  • 템플릿

8.2.2. Variable and constant declarations

변수의 선언문은 이름, 타입, 선택적인 초기화자를 가진다. 상수의 선언문은 변수의 선언문과 비슷하지만 초기화자가 무조건 있어야 한다.

int a; // no initializer
double d = 7; // initializer using the = syntax
std::vector<int> vi (10); // initializer using the () syntax
std::vector<int> vi2 {1, 2, 3, 4}; // initializer using the {} syntax

constexpr int x = 7; // initializer using the = syntax
constexpr int x2 {9}; // initializer using the {} syntax
constexpr int y; // error: no initializer

상수는 항상 초기화자가 있어야 하지만, 변수 선언문에 대해서도 항상 초기화자를 주는 것이 바람직한 선택이다.

void f(int z) {
    int x; // uninitialized
    if (z > x) {}
}

위의 코드에서 x는 초기화되지 않았기 때문에 z > x를 실행하는 것은 정의되지 않은 동작이 된다. 이렇게 되면 컴파일러는 어떤 코드를 생성해야 할지 모르기 때문에 아무 코드나 마음대로 생성하게 된다. 운이 좋으면 프로그램이 죽을 것이고 운이 나쁘면 엉뚱한 결과를 낳게 된다.

8.2.3. Default initialization

기본 생성자가 있는 객체는 초기화자를 제공할 필요가 없다.

std::vector<std::string> v;
std::string s;
while (std::cin >> s) v.push_back(s);

기본 타입에 대해서는 자동 초기화가 되지 않는다. 전역 변수는 0으로 초기화되지만, 전역 변수의 사용은 최소화해야 할 것이다. 지역 변수와 클래스 멤버는 자동 초기화되지 않는다. 명심하라!

8.3. Header files

선언문과 정의문을 어떻게 관리하는가? 보통은 헤더 파일에 선언문을 몰아넣는 방법을 사용한 뒤 #include 로 헤더 파일을 불러오는 방법을 쓴다. 헤더 파일의 확장자는 일반적으로 .h을 쓰고, 소스 파일의 확장자는 .cpp를 쓴다. 강제된 룰은 아니지만 절대 다수의 코드가 그렇게 하고 있으므로 이 관행을 따르도록 하자. 헤더 파일의 #include는 선언문에 대한 정의문을 구현하는 소스 파일과, 선언된 오브젝트를 사용하는 소스 파일 모두에 두어야 한다.

헤더 파일은 많은 소스 파일에서 인클루드될 수 있다. 이는 헤더 파일은 여러 파일에 중복될 수 있는 선언문만 포함해야 한다는 것을 의미한다.

8.4. Scope

스코프는 프로그램의 영역이다. 스코프 내에 선언된 이름은 스코프가 끝날 때까지만 유효하다.

void f() {
    g(); // error: g() isn't yet in scope
}

void g() {
    f(); // OK: f() is in scope
}

void h() {
    int x = y; // error: y isn't yet in scope
    int y = x; // OK: x is in scope
    g(); // OK: g() is in scope
}

스코프의 종류는 다음과 같다.

  • 전역 스코프: 모든 스코프 바깥의 스코프
  • 이름공간 스코프: 전역 스코프 내 이름공간 내의 스코프
  • 클래스 스코프: 클래스 내 스코프
  • 지역 스코프: 함수 인자 리스트 안의 스코프 또는 {} 안의 스코프
  • 명령문 스코프: for문 안

스코프의 목적은 다른 곳에 선언된 이름들끼리 충돌이 나지 않게 위함이다. 아래 코드의 f 내의 x와 g 내의 x는 충돌이 나지 않는다.

void f(int x) { // f is global, x is local to f
    int z = x + 7; // z is local
}
int g(int x) { // g is global, x is local to g
    int f = x + 2; // f is local
    return 2 * f;
}

충돌을 방지하기 위해 가급적 이름을 지역 스코프 안으로 한정시켜라. 중첩 스코프의 사용을 최소화하라.

전역 변수를 가급적 쓰지 말아야 하는 이유는 무엇인가? 어느 함수가 전역 변수를 사용하고 수정하는지 알수 없기 때문이다.

C++에서 중첩 스코프의 용례는 다음과 같다.

  • 클래스 내 함수: 멤버 함수
class C {
    public:
        void f();
        void g() { // a member function can be defined within its class
            // ...
        }
};

void C::f() { // a member definition can be outside its class
    // ...
}
  • 클래스 내 클래스: 멤버 클래스(중첩 클래스)
class C {
public:
    class M {
        // ...
    };
};

이는 복잡한 클래스에 대해서만 쓸모가 있다. 가급적 그런 클래스의 사용을 자제하라.

  • 함수 내 클래스: 지역 클래스
void f() {
    class L {
        // ...
    };
}

가급적 쓰지 마라.

  • 함수 내 함수: 지역 함수 (중첩 함수)
void f() {
    void g() { // illegal
        // ....
    }
}

이는 C++에서 금지되어 있다. 컴파일러가 거부할 것이다.

  • 함수나 다른 블록 내 블록: 중첩 블록
void f(int x, int y) {
    if (x > y) {
        // ...
    } else {
        // ...
        {
            // ...
        }
        // ...
    }
}

스코프 중첩에 따른 들여쓰기를 일관적으로 유지해서 가독성을 높이라. 가독성 낮은 코드는 유지보수하기 힘든 코드이고 유지보수하기 힘든 코드는 버그가 나기 쉬운 코드이다.

8.5. Function call and return

함수는 연산을 표현하는 수단이다. 이를 위해서는 인자와 반환값이 필요하다.

8.5.1. Declaring arguments and return type

C++에서의 함수 선언문은 리턴 타입 뒤에 함수 이름 뒤에 괄호 안에 인자 리스트로 구성된다.

double fct(int a, double d); // declaration of fct (no body)
double fct(int a, double d) { return a * d; } // definition of fct
int current_power(); // current power doesn't take an argument
void increase_power_to(int level); // increase_power doesn't return a value

정의문은 함수 몸체를 포함하고, 정의문이 아닌 선언문은 세미콜론만 따라온다. 인자를 받지 않는 함수라면 인자 리스트를 비워두면 된다. 반환값이 없는 함수라면 리턴 타입에 void를 쓰면 된다. 함수 선언문에서는 함수 인자 이름을 써도 되고 쓰지 않아도 되지만 가독성과 의도 전달을 위해서는 쓰는 것이 낫다. 정의문에서는 함수 몸체 내에서 사용하는 인자라면 써야 한다. 쓰지 않는 인자라면 정의문의 인자 리스트에서 이름을 비워둔다.

int my_find(const std::vector<std::string>& vs, const std::string& s, int) { // 3rd argument unused
    for (size_t i = 0; i < vs.size(); i++) {
        if (vs[i] == s) return i;
    }
    return -1;
}

8.5.2. Returning a value

함수에서 return 문으로 값을 반환할 수 있다.

T f() { // f() returns a T
    V v;
    return v;
}

T x = f();

여기서 f()가 리턴하는 값은 타입 V를 가진 변수 v로 타입 T를 초기화해 얻은 값과 같다. 즉 다음과 같다.

V v;
T t(v); // initialize t with v

값을 리턴하는 함수는 반드시 값을 리턴해야 한다. 대부분의 컴파일러가 잡아주기는 하지만. main()은 이 규칙에서 예외이다. 리턴하지 않는 void 타입의 함수는 함수로부터 빠져나가기 위해 return 문을 쓸 수 있다. void 타입의 함수는 return 문을 쓰지 않아도 된다.

void print_until_s(const std::vector<std::string>& v, const std::string& quit) {
    for(const auto& s : v) {
        if (s == quit) return;
        std::cout << s << '\n';
    }
}

8.5.3. Pass-by-value

함수에 인자를 전달하면 기본적으로 인자들은 복제되어 재 초기화됨으로써 함수 내 지역 변수가 된다.

// pass-by-value (give the function a copy of the value passed)
int f(int x) {
    x = x + 1; // give a local x a new value
    return x;
}

int main() {
    int xx = 0;
    std::cout << f(xx) << '\n'; // write: 1
    std::cout << xx << '\n'; // write: 0, f() doesn't change xx
}

8.5.4. Pass-by-const-reference

작은 오브젝트는 상관이 없지만 큰 오브젝트는 복사로 넘기면 문제가 된다. 복사할 필요가 없을 때는 그 오브젝트의 주소를 넘기면 되는데, 이를 참조자라 하며 &로 쓴다.

void print(const std::vector<double>& v) {
    std::cout << "{ ";
    for (size_t i = 0; i < v.size(); ++i) {
        std::cout << v[i];
        if (i != v.size() – 1) std::cout << ", ";
    }
    std::cout << " }\n";
}

const는 인자를 함수 내에서 변경하지 말라는 지시자이다. 변경을 시도하면 컴파일 에러가 난다.

8.5.5. Pass-by-reference

복사는 하고 싶지 않지만 함수 내에서 오브젝트를 수정하고 싶을 때는 const를 붙이지 않은 참조자로 넘긴다.

void init(std::vector<double>& v) { // pass-by-reference
    for (size_t i = 0; i < v.size(); i++) v[i] = i;
}

참조자는 다음과 같이 일반 변수로 쓸 수도 있다. 아래의 코드에서 val2를 수정하면 v[f(x)][g(y)]가 수정된다.

double val = v[f(x)][g(y)]; // val is a value of v[f(x)][g(y)]
double& val2 = v[f(x)][g(y)]; // val2 is a reference to v[f(x)][g(y)]
// pass-by-reference (let the function refer back to the variable passed)
int f(int x) {
    x = x + 1;
    return x;
}

int main() {
    int xx = 0;
    std::cout << f(xx) << '\n'; // write: 1
    std::cout << xx << '\n'; // write: 1, f() changed the value of xx
}

8.5.6. Pass-by-value vs. pass-by-reference

넘긴 오브젝트를 수정하고 싶으면 const 없는 참조자로 넘긴다. 수정될 필요 없는 오브젝트를 넘길 때는 작은 오브젝트라면 값으로, 큰 오브젝트라면 const 참조자로 넘긴다. 참조자 인자로 넘겨서 수정하는 것보다는 수정한 오브젝트를 직접 리턴하는 것이 낫다.

참조자는 우측값에 쓸 수 없다. const 참조자는 가능하다. const 참조자로 우측값을 넘기면 임시 변수를 컴파일러가 생성해 이를 함수에 넘겨준다.

void g(int a, int& r, const int& cr) {
    ++a;                  // change the local a
    ++r;                  // change the object referred to by r
    int x = cr;         // read the object referred to by cr
}

int main() {
    int x = 0;
    int y = 0;
    int z = 0;

    g(x,y,z);      // x==0; y==1; z==0
    g(1,2,3);      // error: reference argument r needs a variable to refer to
    g(1,y,3);      // OK: since cr is const we can pass a literal
}

일반 참조자는 언제 쓸까? 컨테이너 등 큰 오브젝트를 조작하거나, 여러 오브젝트를 조작할 때 쓴다. 보통은 여러 오브젝트를 한 번에 조작하지 않는 것이 좋다. 일반 참조자를 함수 인자에서 본다면 그 함수가 기대한 동작을 하는지도 체크해야 한다.

8.5.7. Argument checking and conversion

함수 인자를 넘길 때 타입이 맞지 않는다면 형변환이 가능할 경우 형변환이 일어난다.

void f(double x);
void g(int y)
{
    f(y);
    double x = y;      // initialize x with y (see §8.2.2)
}

이 형변환은 자동적으로 일어나므로 의도치 않은 결과를 낳게 되기 쉽다. 이것이 의도적이라면 static_cast를 사용하여 프로그래머들에게 의도를 알리는 것이 좋다.

8.5.8. Function call implementation

함수에서 전역 변수는 쓰지 않는 것이 좋다. 어느 함수가 전역 변수를 수정해서 그 변수가 어떻게 되었는지 알 수 없기 때문이다.

함수가 호출될 때 일반적인 구동 환경에서는 컴파일러가 인자-구현 내용의 순서로 된 콜 스택을 생성한다. 이를 함수 동작 기록이라 한다. 같은 함수를 호출해도 호출시의 상태가 다르면 다른 동작 기록이 생성된다. 함수가 리턴될 때에는 스택이 쌓인 순서의 반대로 풀린다.

8.5.9. constexpr functions

컴파일 타임에 계산하기를 원하는 함수는 constexpr를 붙인다. 이 경우 인자들이 컴파일 타임에 계산되는 상수 표현식이라면 그 결과도 컴파일 타임에 계산할 수 있다. 그렇지 못하다면 다른 함수들과 똑같이 그냥 런타임에 계산된다.

constexpr double xscale = 10;       // scaling factors
constexpr double yscale = 0.8;

constexpr Point scale(Point p) { return {xscale*p.x,yscale*p.y}; };

void user(Point p1) {
    Point p2 {10,10};

    Point p3 = scale(p1);     // OK: p3 == {100,8}; run-time evaluation is fine
    Point p4 = scale(p2);     // p4 == {100,8}

    constexpr Point p5 = scale(p1);   // error: scale (p1) is not a constant expression
    constexpr Point p6 = scale(p2);   // p6 == {100,8}
}

constexpr 함수는 리턴값이 있는 순수 함수여야 한다. 다음은 컴파일 에러가 난다.

int glob = 9;

constexpr void bad(int& arg) {       // error: no return value
    ++arg;                                   // error: modifies caller through argument
    glob = 7;                              // error: modifies nonlocal variable
}

8.6. Order of evaluation

프로그램이 실행되는 중 변수를 만날 때는 그 오브젝트가 생성되고, 스코프가 끝나면 그 변수는 소멸한다. 전역 변수는 main()이 실행되기 전에 생성되고, 프로그램이 종료될 때 소멸한다. 컴파일러는 불필요한 메모리 생성/소멸을 잘 최적화해준다.

8.6.1. Expression evaluation

C++에서는 같은 표현식 내에서 같은 변수를 2번 이상 수정할 수 없다. 그러면 정의되지 않은 행동이 되어, 컴파일러는 아무 코드나 마음대로 생성하게 된다. C++17에서는 이 제한이 완화되었긴 하지만 그냥 그런 행동을 시도하지 않는 것이 좋다.

i = ++i + 2;       // undefined behavior until C++11
i = i++ + 2;       // undefined behavior until C++17
f(i = -2, i = -2); // undefined behavior until C++17
f(++i, ++i);       // undefined behavior until C++17, unspecified after C++17
i = ++i + i++;     // undefined behavior
std::cout << i << i++; // undefined behavior until C++17
a[i] = i++;       // undefined behavior until C++17
n = ++i + i;      // undefined behavior

8.6.2. Global initialization

같은 번역 단위 내에서의 전역 변수는 등장한 순서대로 초기화된다.

// file f1.cpp
int x1 = 1;
int y1 = x1+2;        // y1 becomes 3

전역 변수는 가급적 쓰지 않는 것이 좋다. 또 다른 문제는 서로 다른 번역 단위 내에서의 전역 변수 초기화 순서는 정의되지 않는다는 점이다.

// file f2.cpp
extern int y1;
int y2 = y1+2;         // y2 becomes 2 or 5

전역 변수가 정말 필요하다면 어떻게 해야 할까? 한 번만 초기화되도록 하는 것이 좋다.

const Date& default_date()
{
    static const Date dd(1970, 1, 1);        // initialize dd first time we get here
    return dd;
}

static 지역 변수는 함수가 최초로 호출되기 전의 한 번만 초기화된다. 이 때 리턴 타입은 호출자 쪽에서 수정하는 일을 막기 위해 const 참조자로 해 주었다.

8.7. Namespaces

함수와 클래스들은 그 자체에 이름을 붙여서 구별할 수는 있지만 서로 의미가 연관이 있는 함수들과 클래스들을 이름으로 묶어야 할 때가 있다. 이런 그룹핑을 네임스페이스라 한다. 이는 이름 충돌을 막을 때 유용하다.

namespace TextLib {
    class Text { /* ... */ };
    class Glyph { /* ... */ };
    class Line { /* ... */ };
}

이 경우 TextLib::Text 등으로 클래스를 호출해 쓸 수 있다.

8.7.1. using declarations and using directives

C++ 표준 라이브러리의 구성물들은 std 네임스페이스에 정의되어 있다. 본래는 이렇게 써야 한다.

#include <string>                // get the string library
#include <iostream>           // get the iostream library
int main() {
    std::string name;
    std::cout << "Please enter your first name\n";
    std::cin >> name;
    std::cout << "Hello, " << name << '\n';
}

이를 다 치는 것은 귀찮으므로 using 지시자를 사용할 수 있다. 이를 써 주면 string, cout, cin 등으로 쓸 수 있다.

using std::string; // string means std::string
using std::cout; // cout means std::cout
using std::cin; // cout means std::cin

또는 다음과 같이 직접 네임스페이스 지시자를 쓸 수도 있다.

#include <string>                // get the string library
#include <iostream>           // get the iostream library
using namespace std;  // make names from std directly accessible

int main() {
    string name;
    cout << "Please enter your first name\n";
    cin >> name;
    cout << "Hello, " << name << '\n';
}

네임스페이스 지시자를 직접 쓰는 것은 별로 좋은 선택이 아니다. 이름의 네임스페이스 정보를 잃어버리기 때문이다. 헤더에 쓰는 것은 최악의 선택이다. 그렇게 하지 말자.

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Google photo

Google의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중