C++ 디폴트 복사생성자에서 멤버변수가 배열일시 복사 원리 질문드립니다

조회수 1712회
class baseDMA
{
private:
    char* label;
    int rating;
public:
    baseDMA(const char* l="NULL", int r =0);
    baseDMA(const baseDMA& rs);
    virtual ~baseDMA();
    baseDMA& operator=(const baseDMA& rs);
    friend ostream& operator<<(ostream& os, const baseDMA& rs);
};

//DMA를 사용하지 않는 파생클래스
//파괴자가 필요없다
//암시적 복사 생성자를 사용한다
//암시적 대입연산자를 사용한다
class lacksDMA : public baseDMA
{
private:
    enum { COL_MEN = 40 };
    char color[COL_MEN];
public:
    lacksDMA(const char* c="blank", const char*l="NULL", int r = 0);
    lacksDMA(const char* c, const baseDMA& rs);
    friend ostream& operator<<(ostream& os,const lacksDMA& ls);
};
int main()
{
    lacksDMA balloon2(balloon); //ballon은 lacksDMA 객체이고,미리 선언,초기화 되어있다고 가정

    return 0;
}

안녕하세요
저로써는 도저히 알 방법이 없어서 이곳저곳 찾고 물어보다가 답이안나오고 마지막이라 생각하고 질문드립니다.

다름이 아니라 디폴트 복사생성자 호출시 멤버간 복사에 관한것인데요. 그중 멤버가 배열인 경우에는 어떤 원리로 복사가 되는지 궁금합니다.

위 소스는 파생클래스에서 동적메모리 대입을 사용하는가 사용하지 않는가에 따른 복사생성자,대입연산자, 파괴자의 정의 유무를 설명하는 소스입니다.

해당 소스는 파생클래스에서 동적메모리를 사용하지 않기때문에 파생클래스에는 디폴트 복사생성자,디폴트 복사생성자, 디폴트 대입연산자만 있으면 됩니다.즉 이 세가지를 따로 정의하지 않아도 된다는 뜻이죠

그런데 문제는 디폴트 복사생성자에서 궁금증이 생깁니다.main 함수를 보시면
lacksDMA 객체인 balloon은 balloo2를 초기화 하고 있습니다.

여기서 lacksDMA에서는 복사생성자가 따로 필요하지 않기때문에 balloo2의 초기화를 위해서 balloon 객체를 사용하고 따라서 디폴트 복사생성자가 호출됩니다.

복사생성자는 멤버간의 복사가 이루어진다고 배웠습니다.그런데 멤버 변수가 배열인 경우에는 어떤 원리로 복사가 이루어지는지 궁금하네요.

단순 int a = b; 이렇게 단순 대입이면 좋겠지만 배열에서는 그게 안되지 않습니까? 예를들어

char a[10] = "School";
char b[10];
b = a; //에러

예를든 소스처럼 이건 잘못된 코드이지 않습니까? 이런 char 형 배열에서 복사를 하려면 strcpy() 또는 strncpy() 함수를 사용하거나 반복문을 통해서 대입하거나 등의 방법을 이용해야 할텐데

마찬가지로 해당 lacksDMA에서 멤버변수는 어떻게 복사가 되나요? 내부적으로 strcpy 또는 strncpy 함수를 사용하거나 해서 복사가 이루어지는 것인가요?

멤버별 복사라고해서 단순 대입으로 인한 복사의 개념인줄 알았는데 생각해보니 배열일 경우를 생각하니 이건 아닌거같다고 생각해서 질문드립니다. 부탁드립니다 너무 궁금하네요

1 답변

  • 좋아요

    1

    싫어요
    채택 취소하기

    해당 과정은 컴파일러에 따라 구현방법이 다르며, 멤버 변수가 POD 타입이냐 아니면 복사 생성자를 갖는 클래스 타입이냐에 따라 달라지게 됩니다.

    또한 자동 생성된 복사 생성자는 C++ 언어로 표현하지 못할 수 있습니다. 이러한 부분은 어셈블리 언어로 보면 확실히 나타나며 이해를 도울 수 있습니다.

    일단 자동으로 생성되는 복사 생성자를 C++ 로 직접 표현해 보고 이 후 어셈블리 코드를 보여 설명하겠습니다.

    POD 의 경우

    POD 타입의 경우 strcpy() 와 유사한 memcpy() 을 통해 복사합니다. 즉, 말씀하신 코드의 경우 아래와 같은 복사 생성자가 자동으로 작성된다고 생각하시면 됩니다.

    lacksDMA::lacksDMA(lacksDMA const& rhs)
        : baseDMA(rhs)
    {
        std::memcpy(color, rhs.color, sizeof(color));
    }
    

    제가 말씀 드린 것이 맞는지 확인하기 위해서 어셈블리 코드를 봐보도록 하죠.

    작성해 주셨던 코드 어셈블리 코드로 변환하한 내용이 https://godbolt.org/g/V6UpcV 에 있습니다. 해당 어셈블리 코드를 보면 아래와 같은 내용을 볼 수 있습니다.

    lacksDMA::lacksDMA(lacksDMA const&):
      push rbp
      mov rbp, rsp
      sub rsp, 16
      mov QWORD PTR [rbp-8], rdi
      mov QWORD PTR [rbp-16], rsi
      mov rax, QWORD PTR [rbp-8]
      mov rdx, QWORD PTR [rbp-16]
      mov rsi, rdx
      mov rdi, rax
      call baseDMA::baseDMA(baseDMA const&)
      mov edx, OFFSET FLAT:vtable for lacksDMA+16
      mov rax, QWORD PTR [rbp-8]
      mov QWORD PTR [rax], rdx
      mov rcx, QWORD PTR [rbp-8]
      mov rsi, QWORD PTR [rbp-16]
      mov rax, QWORD PTR [rsi+20]
      mov rdx, QWORD PTR [rsi+28]
      mov QWORD PTR [rcx+20], rax
      mov QWORD PTR [rcx+28], rdx
      mov rax, QWORD PTR [rsi+36]
      mov rdx, QWORD PTR [rsi+44]
      mov QWORD PTR [rcx+36], rax
      mov QWORD PTR [rcx+44], rdx
      mov rax, QWORD PTR [rsi+52]
      mov QWORD PTR [rcx+52], rax
      nop
      leave
      ret
    

    보시면 lacksDMA::lacksDMA(lacksDMA const&): 라는 부분이 존재하는데 이게 컴파일러가 자동으로 생성해준 복사 생성자 입니다. 그 아래의 내용은 해당 복사 생성자의 내용이 됩니다.

    내용 중 call baseDMA::baseDMA(baseDMA const&) 이 있는 데 여기서 부모 클래스인 baseDMA 의 복사 생성자를 호출합니다.

    이후 아래의 코드가 배열을 복사하는 어셈블리가 되겠습니다.

      mov rax, QWORD PTR [rsi+20] ;// balloon.color[0] ~ balloon.color[7] 를 읽기
      mov rdx, QWORD PTR [rsi+28] ;// balloon.color[8] ~ balloon.color[15] 읽기
      mov QWORD PTR [rcx+20], rax ;// 읽은 값을 balloon2.color[0] ~ balloon2.color[7] 에 쓰기
      mov QWORD PTR [rcx+28], rdx ;// 읽은 값을 balloon2.color[8] ~ balloon2.color[15] 에 쓰기
      mov rax, QWORD PTR [rsi+36] ;// balloon.color[16] ~ balloon.color[23] 를 읽기
      mov rdx, QWORD PTR [rsi+44] ;// balloon.color[24] ~ balloon.color[31] 를 읽기
      mov QWORD PTR [rcx+36], rax ;// 읽은 값을 balloon2.color[16] ~ balloon2.color[23] 에 쓰기
      mov QWORD PTR [rcx+44], rdx ;// 읽은 값을 balloon2.color[24] ~ balloon2.color[31] 에 쓰기
      mov rax, QWORD PTR [rsi+52] ;// balloon.color[32] ~ balloon.color[39] 를 읽기
      mov QWORD PTR [rcx+52], rax ;// 읽은 값을 balloon2.color[32] ~ balloon2.color[39] 에 쓰기
    

    이 어셈블리에서는 배열을 8바이트(QWORD) 단위로 읽어 들여 총 5번에 걸쳐 복사하게 됩니다. 1 바이트 단위가 아닌 QWORD 단위로 복사되는 이유는 그 쪽이 성능이 좋기 때문입니다.

    처음에 제가 memcpy() 를 호출한다 말씀 드렸지만, 보시는 거와 같이 5번에 걸쳐 복사 명령이 이루어 졌습니다. 왜 그런가하면 컴파일러가 memcpy() 를 호출하는 것보다 복사 5번 연속적으로 수행하는 것이 빠르다고 판단하고 코드를 작성했기 때문입니다.

    그러면 어떤 상황에서 memcpy() 를 호출 할 수 있을까요? color 배열 크기를 512 로 증가 시키면 아래와 같은 결과가 나옵니다.

      mov eax, eax
      mov rsi, rdx
      mov rcx, rax
      rep movsq
    

    https://godbolt.org/g/dPHtFn 이곳을 보시면 memcpy() 가 아래와 같이 변환되는 것을 볼 수 있으며, 이를 통해 위의 color 을 512 로 변경했을 때와 유사한 코드가 작성된 것을 알 수 있습니다.

      mov ecx, 512
      mov rdi, rax
      mov rsi, rdx
      rep movsq
    

    즉, 얼마나 많은 양의 메모리를 복사 하게 되느냐에 따라 memcpy() 를 쓸지 말지가 결정되게 됩니다.

    클래스 타입의 경우

    POD 의 경우 단순히 메모리를 복사만 하면 멤버 변수를 복사할 수 있기 때문에 memcpy() 를 이용하게 됩니다. 하지만 클래스 타입의 경우 프로그래머가 직접 작성할 수 있는 복사 생성자가 존재하며, 작성 내용에 따라 복사의 방법이 다르게 됩니다.

    따라서 자동으로 생성되는 복사 생성자는 클래스 타입을 배열로 갖는 멤버 변수를 복사할 때, 각각의 배열 원소의 복사 생성자를 호출하여 복사 생성을 진행 합니다.

    아래의 코드를 기준으로 설명 드리겠습니다.

    #include <string>
    
    class Array
    {
    public:
        std::string elements[100];
    };
    
    int main()
    {
        Array a1;
        Array a2(a1);
        return 0;
    }
    

    이 때 elements 는 클래스 타입의 원소를 갖는 배열이기 때문에 POD 와 같이 memcpy() 를 사용하는 복사 생성자가 생성되지 않고 아래와 같이 각 원소를 복사 생성하는 코드가 자동 생성됩니다.

    Array::Array(Array const& rhs)
    {
        // elements 는 메모리 공간만 할당되고 기본 생성자는 호출되지 않았다 가정
        for (std::size_t i = 0; i < 100; ++i)
        {
            new (&elements[i]) std::string(rhs.elements[i]);
        }
    }
    

    이 때 elements 는 기본 생성자가 호출되어 생성되지 않고 메모리 공간만 할당된 상태입니다. 자동 생성된 생성자는 루프를 돌며 원소 하나 하나 new (&elements[i]) std::string(rhs.elements[i]); 와 같이 원소의 복사 생성자를 호출합니다.

    이 부분도 어셈블리 코드(https://godbolt.org/g/WQDCEw) 를 보면 아래와 같은 내용이 있습니다.

    Array::Array(Array const&):
      push rbp
      mov rbp, rsp
      push r14
      push r13
      push r12
      push rbx
      sub rsp, 16
      mov QWORD PTR [rbp-40], rdi
      mov QWORD PTR [rbp-48], rsi
      mov rbx, QWORD PTR [rbp-40]
      mov r12d, 99
      mov r13, QWORD PTR [rbp-48]
      mov r14, rbx
    .L10:
      test r12, r12
      js .L15
      mov rsi, r13
      mov rdi, r14
      call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
      add r14, 32
      add r13, 32
      sub r12, 1
      jmp .L10
      mov r13, rax
      test rbx, rbx
      je .L12
      mov eax, 99
      sub rax, r12
      sal rax, 5
      lea r12, [rbx+rax]
    .L13:
      cmp r12, rbx
      je .L12
      sub r12, 32
      mov rdi, r12
      call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::~basic_string()
      jmp .L13
    .L12:
      mov rax, r13
      mov rdi, rax
      call _Unwind_Resume
    .L15:
      nop
      add rsp, 16
      pop rbx
      pop r12
      pop r13
      pop r14
      pop rbp
      ret
    

    여기서 아래 코드가 루프를 돌면서 각 배열 원소의 복사 생성자를 호출하는 내용이 됩니다.

    .L10:
      test r12, r12
      js .L15
      mov rsi, r13
      mov rdi, r14
      call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
      add r14, 32
      add r13, 32
      sub r12, 1
      jmp .L10
    

    test r12, r12js .L15 를 통해 루프의 끝(마지막 원소 까지 생성이 끝났는지)에 왔는지 확인하고 끝에 왔다면 .L15 레이블로 점프를 하게 됩니다. 아직 생성해야할 배열의 원소가 남아 있다면 call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) 복사 생성자를 호출하게 됩니다. 그리고 jmp .L10 를 통해 test r12, r12 코드가 있는 위치로 점프를 하여 루프를 반복합니다.

    결론

    자동으로 생성되는 복사 생성자는 컴파일러마다 내용이 다르며, 클래스의 정의에 따라도 다릅니다.

    일반적으로 설명하면 POD 타입을 원소로 갖는 배열의 경우 memcpy() 를 통해 배열을 복사하게 되고, 클래스 타입을 원소로 갖는 배열의 경우 루프를 돌며 원소 하나하나의 복사 생성자를 호출하는 식으로 동작 합니다.

    • 정말 감사합니다. 중요하진 않은걸지 몰라도 공부하는데 의문점이 생겨서 찾아보고 다른 사이트에도 물어보고 했지만 답변을 얻을 수 없어 짜증이 나던차에,, 마지막이라 생각하고 가입까지하고 질문했는데 너무 자세한 답변까지 달아주시니 정말 감사하네요. 다만, 많이 초보자이다보니 POD가 무엇인지 memcpy() 함수가 무엇인지 몰라 내용을 이해하기 위해 찾아보고 다시 내용을 보고 이해하고 또 어셈블리어를 볼줄 몰라 그냥 설명해주신대로 이부분은 이렇다 하고 받아들이기만해서 이해하는데 다소 시간이 걸렸지만 궁금증이 해소되니 정말 좋네요 답변 길이를 보니 시간이 다소 걸리셨을거 같기도한데 또 제가 초보자라 모르는 용어가 나왔을때 이해가 안될까봐 걱정했는데 보니 이해가 잘되게 설명해주셔서 정말 좋았습니다 시간내주셔서 정말 고맙습니다 codeslave 2018.5.6 00:30

답변을 하려면 로그인이 필요합니다.

프로그래머스 커뮤니티는 개발자들을 위한 Q&A 서비스입니다. 로그인해야 답변을 작성하실 수 있습니다.

(ಠ_ಠ)
(ಠ‿ಠ)