Microsoft MVP성태의 닷넷 이야기
VC++: 81. 프로그래밍에서 borrowing의 개념 [링크 복사], [링크+제목 복사],
조회: 23448
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
 

프로그래밍에서 borrowing의 개념

아래와 같은 글에서,

왜 많은 사람들이 Go 언어를 싫어할까?
; http://yisangwook.tumblr.com/post/100383515974/why-everyone-hates-go

Go에 없는 것으로 "there's no borrowing" 이라는 말이 나옵니다. 위의 글을 쓴 사람도 그걸 인용하면서 borrowing이 뭔지 궁금해 하는데요. 저도 궁금했습니다. ^^ (처음 봤습니다.)

모르는 개념을 하나씩 익히는 것도 좋으니 이참에 한번 찾아봤는데요. 다음의 글이 검색됩니다.

Rust 언어 튜토리얼 - 11 빌린 포인터
; http://sarojaba.github.io/rust-doc-korean/doc/tutorial.html

"
Rust의 빌린 포인터는 범용의 참조 타입이다. 소유된 박스와는 대조적으로, 소유된 박스의 홀더는 메모리 참조의 소유자이다. 빌린 포인터는 암묵적인 소유권이 절대 아니다. 포인터는 어떤 객체로든 빌려질 수 있고, 컴파일러는 객체의 생명주기보다 오래 살 수 없다는 것을 검증한다.
"


"왜 많은 사람들이 Go 언어를 싫어할까?" 글에서 Go 언어를 비판하는 사람들에 "Rust" 언어를 사용하는 층이 있다고 하니, 아마도 Rust 언어의 이 개념을 두고 한 이야기가 맞는 것 같습니다.

근데, 솔직히 "Rust 언어 튜토리얼 - 11 빌린 포인터"의 설명으로는 무슨 개념인지 감이 안 옵니다. 제가 이해력이 부족한 듯합니다. 그래서 좀 더 검색해 보니 다음의 글이 나옵니다.

Who Needs Garbage Collection? 글의 덧글 "Borrowing, or keeping"
; http://lambda-the-ultimate.org/node/5007

덧글의 설명이 제법 충실합니다.

Borrowing, or keeping

I'd been down this road before, years ago, in the context of trying to make C++ pointer-safe via reference counting without killing performance.
It's possible, but may be too unwieldy. I called Rust's "borrow" concept "keeping". "keep" becomes a qualifier on parameters, like "const".

More specifically, function parameters which are references or pointers would have four access permissions - read, write, keep, and delete. "Read" and "Write" are implied; "const" turns off write permission. That's standard C/C++. "Keep" permission means that a function can keep a copy of a parameter after the function returns. "delete" permission means the function can delete the object pointed to.

Lack of "keep" permission has several implications. Any copy of a pointer or reference must be to a scope that will not outlive the source scope. So you can copy a non-keep pointer/reference for use in an inner block, or pass it to another non-keep parameter. Rust does much the same thing.

In a reference counted system, it is not necessary to update reference counts for non-keep pointers. They must have had a non-zero reference count at scope entrance, and they will have the same reference count at scope exit. So there's a big overhead reduction for non-keep parameters.

It's possible to infer that a local copy of a pointer is non-keep. Iterators, for example, are almost always non-keep. Recognizing this eliminates most reference counting in inner loops.

All parameters to the standard C library functions and the Linux API are "non-keep". This is also true of most math libraries. "Non-keep" is the normal case for functions.

This is thus a way to do reference counting without excessive overhead. Most of the things that are done very frequently are done via non-keep parameters to functions.

A long, long time ago I tried to talk the people who were designing what would become Java into this. I was not successful.


이 글과 함께 다음의 소스 코드가 담긴 설명을 보니 그나마 이해되기 시작합니다.

A 30 minute introduction to Rust - Ownership
; http://words.steveklabnik.com/a-30-minute-introduction-to-rust




이쯤에서 제 나름대로 다시 정리해 볼까요? ^^ 다음의 코드를 보겠습니다.

int GetValue()
{
    return 5;
}

이를 깊게 들어가 보면 GetValue 함수가 반환될 때 "MOV EAX, 5", "ret" 라는 코드가 실행되는 것을 볼 수 있습니다. 즉, 반환값이 EAX 레지스터에 담기는 것입니다. GetValue 예제처럼 CPU 워드(WORD) 단위의 반환값이라면 상관없지만, 그것이 워드 범위를 넘어가면 문제가 됩니다. 그런 경우에는 CPU 레지스터에 담을 수 없기 때문에 메모리에 값을 보관 후 그 메모리를 가리키는 주소를 EAX에 담아 넘기는 방법이 사용됩니다.

일례로 다음과 같은 경우입니다.

#include "stdafx.h"

char *GetValues1()
{
    char buf[10] = "test1";
    return buf;
}

int _tmain(int argc, _TCHAR* argv[])
{
    char *result1 = GetValues1();
    printf("%s\n", result1);

    return 0;
}

/*
이 코드를 Visual C++ 2013 / Debug 빌드로 했을 때 화면에는 "test1"이 아닌 쓰레기 값이 출력됩니다. (Release 빌드시 최적화로 인해 극적으로 ^^; 정상값이 출력됩니다.)
*/

buf 변수는 10바이트의 영역이 확보되지만 이것은 CPU 레지스터에 담길 수 없는 용량입니다. 그래서 메모리에 상주하게 되고 EAX에는 buf의 메모리 주소가 담겨 반환됩니다.

여기서 문제는 그 메모리 주소가 스레드의 스택이라는 점입니다. 스택은 함수가 불릴 때마다 가변적으로 사용되는데, GetValues1 함수가 불렸을 때 C/C++ 컴파일러는 스택에 10바이트 공간을 예약하는 기계어를 출력해서 실행시 스택 공간을 확보하는 작업을 합니다. 하지만, GetValues 함수의 마지막 - "return buf"를 하는 시점에 확보된 10바이트 스택 영역은 다시 차감되고 이후의 메서드 호출에서 그 영역은 덮어 써질 수 있습니다. 쓰레기 값이 출력되는 것은 그 이유입니다.

C/C++에서는 이런 문제를 해결하기 위해 반드시 동적 할당을 해야 합니다.

char *GetValue()
{
    char *pBuf = new pBuf[5];
    strcpy_s(pBuf, 5, "test");
    return pBuf;
}

그리고, 이렇게 반환받은 메모리는 반드시 해제해야 합니다.

char *pValue = GetValue();
// ... pValue 사용
delete [] pValue; // 반드시 해제

말은 쉽지만, 이 때문에 C/C++ 개발자는 메모리 할당/해제에 따른 적지 않은 고통을 겪게 됩니다. 게다가 모든 코드를 자신이 작성한 경우라면 상관없지만 그렇지 않은 경우는 반드시 매뉴얼을 읽어봐야만, 그것이 반환받는 값을 호출 측에서 해제를 해야 하는지 알 수 있습니다. 예를 들어, 어떤 C/C++ 개발자는 호출자가 해제를 안해도 되게끔 다음과 같이 함수를 작성할 수도 있습니다.

char *GetValues2()
{
    static char buf[10] = "test1";
    return buf;
}

static이기 때문에(또는 전역 변수를 사용했을 수도 있는!) 이런 경우는 호출 측에서 메모리 해제를 해서는 안됩니다.

참고로, MSDN 문서에서 Win32 API 설명 중에 OUT 인자로 명시되는 경우를 볼 수 있는데요.

GetEnvironmentVariable function
; https://learn.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-getenvironmentvariablea

DWORD WINAPI GetEnvironmentVariable(
  _In_opt_   LPCTSTR lpName,
  _Out_opt_  LPTSTR lpBuffer,
  _In_       DWORD nSize
);

마이크로소프트의 경우, 이런 OUT 인자는 호출 측에서 반드시 메모리를 확보해서 전달하는 식으로 처리하고 있습니다. 즉, 다음과 같이 사용하라는 것입니다.

wchar_t buf[4096];
GetEnvironmentVariable(L"VAR", buf, 4096);

따라서 C/C++ 언어에서 포인터 변수가 다뤄질 때는, 반드시 그 할당의 주체를 확인해서 사용해야만 안전을 보장할 수 있습니다.




이런 문제를 Rust 언어에서는 borrow 개념을 통해 해결하고 있습니다. "A 30 minute introduction to Rust - Ownership" 글의 예제를,

fn dangling() -> &int {
    let i = 1234;
    return &i;
}

fn add_one() -> int {
    let num = dangling();
    return *num + 1;
}

C/C++ 코드로 바꿔 보면 이럴 텐데요.

int *dangling()
{
    int i = 1234;
    return &i;
}

int add_one()
{
    int *num = dangling();
    return *num + 1;
}

스택에 있는 값을 반환한다는 의미에서 이 코드는 위험한데도 불구하고 C/C++은 정상적으로 컴파일하는 반면, Rust 언어에서는 이를 감지하고 다음과 같은 컴파일 오류를 낸다고 합니다.

temp.rs:3:11: 3:13 error: borrowed value does not live long enough
temp.rs:3     return &i;

temp.rs:1:22: 4:1 note: borrowed pointer must be valid for the anonymous lifetime #1 defined on the block at 1:22...
temp.rs:1 fn dangling() -> &int {
temp.rs:2     let i = 1234;
temp.rs:3     return &i;
temp.rs:4 }

temp.rs:1:22: 4:1 note: ...but borrowed value is only valid for the block at 1:22
temp.rs:1 fn dangling() -> &int {      
temp.rs:2     let i = 1234;            
temp.rs:3     return &i;               
temp.rs:4  }                            
error: aborting due to previous error

그리고, 이 컴파일 오류를 접한 Rust 개발자는 (제가 몰랐던 바로 그 "borrowing"이라고 알려진) "빌린 포인터(borrowed pointer)" 구문을 이용해 다음과 같이 해결할 수 있다는 것입니다.

fn dangling() -> ~int {
    let i = ~1234;
    return i;
}

fn add_one() -> int {
    let num = dangling();
    return *num + 1;
}

이렇게 "~" 연산자를 이용해 "unique pointer"를 사용하면 Rust 컴파일러는 해당 변수 i의 값을 스택에 할당하지 않고 그것의 사용 해제 시점을 계산해 자동으로 할당/해제하는 코드를 (개발자 대신) 넣어주는 것입니다.

"borrowing"이란 것이 어느 상황을 가리키는 용어인지 이제 이해하시겠죠? ^^

여기서 다시 "Who Needs Garbage Collection? 글의 덧글 "Borrowing, or keeping"" 글의 내용을 보면 이런 문구가 나옵니다.

I called Rust's "borrow" concept "keeping". 

'빌려온다'라는 것보다는 '유지한다'는 것이 훨씬 좋은 설명이라는 것에 공감합니다. Rust의 "빌린 포인터(borrowed pointer)"는 컴파일러가 자동으로 해당 변수를 필요한 시점까지 유지해 주는 기능이라고 해석되는 것이 더 자연스럽습니다.




이 개념이 GC가 도입된 언어를 사용하는 개발자에게는 낯설을 수밖에 없습니다. 예를 들어, (자바도 마찬가지이고) C#으로 다음의 코드를 만들면,

char[] GetValues()
{
    char[] buf = { 't', 'e', 's', 't' };
    return buf;
}

buf 인스턴스는 GC 힙에 할당되고, 이후 GC에 의해 관리되어 사용되지 않는 시점에 다음번 가비지 컬렉션 수집에서 자동으로 해제되기 때문입니다. C/C++과 같은 해제의 부담이 없기 때문에 애당초 "borrowing" 개념이 필요없는 것입니다.




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 6/15/2023]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




... 121  122  123  124  125  126  127  128  129  130  131  [132]  133  134  135  ...
NoWriterDateCnt.TitleFile(s)
1756정성태9/23/201427486기타: 48. NVidia 제품의 과다한 디스크 사용 [2]
1755정성태9/22/201434280오류 유형: 241. Unity Web Player를 설치해도 여전히 설치하라는 화면이 나오는 경우 [4]
1754정성태9/22/201424649VC++: 80. 내 컴퓨터에서 C++ AMP 코드가 실행이 될까요? [1]
1753정성태9/22/201420611오류 유형: 240. Lync로 세미나 참여 시 소리만 들리지 않는 경우 [1]
1752정성태9/21/201441071Windows: 100. 윈도우 8 - RDP 연결을 이용해 VNC처럼 사용자 로그온 화면을 공유하는 방법 [5]
1751정성태9/20/201438945.NET Framework: 464. 프로세스 간 통신 시 소켓 필요 없이 간단하게 Pipe를 열어 통신하는 방법 [1]파일 다운로드1
1750정성태9/20/201423832.NET Framework: 463. PInvoke 호출을 이용한 비동기 파일 작업파일 다운로드1
1749정성태9/20/201423732.NET Framework: 462. 커널 객체를 위한 null DACL 생성 방법파일 다운로드1
1748정성태9/19/201425385개발 환경 구성: 238. [Synergy] 여러 컴퓨터에서 키보드, 마우스 공유
1747정성태9/19/201428478오류 유형: 239. psexec 실행 오류 - The system cannot find the file specified.
1746정성태9/18/201426106.NET Framework: 461. .NET EXE 파일을 닷넷 프레임워크 버전에 상관없이 실행할 수 있을까요? - 두 번째 이야기 [6]파일 다운로드1
1745정성태9/17/201423035개발 환경 구성: 237. 리눅스 Integration Services 버전 업그레이드 하는 방법 [1]
1744정성태9/17/201431063.NET Framework: 460. GetTickCount / GetTickCount64와 0x7FFE0000 주솟값 [4]파일 다운로드1
1743정성태9/16/201420985오류 유형: 238. 설치 오류 - Failed to get size of pseudo bundle
1742정성태8/27/201426972개발 환경 구성: 236. Hyper-V에 설치한 리눅스 VM의 VHD 크기 늘리는 방법 [2]
1741정성태8/26/201421334.NET Framework: 459. GetModuleHandleEx로 알아보는 .NET 메서드의 DLL 모듈 관계파일 다운로드1
1740정성태8/25/201432521.NET Framework: 458. 닷넷 GC가 순환 참조를 해제할 수 있을까요? [2]파일 다운로드1
1739정성태8/24/201426542.NET Framework: 457. 교착상태(Dead-lock) 해결 방법 - Lock Leveling [2]파일 다운로드1
1738정성태8/23/201422048.NET Framework: 456. C# - CAS를 이용한 Lock 래퍼 클래스파일 다운로드1
1737정성태8/20/201419765VS.NET IDE: 93. Visual Studio 2013 동기화 문제
1736정성태8/19/201425576VC++: 79. [부연] CAS Lock 알고리즘은 과연 빠른가? [2]파일 다운로드1
1735정성태8/19/201418212.NET Framework: 455. 닷넷 사용자 정의 예외 클래스의 최소 구현 코드 - 두 번째 이야기
1734정성태8/13/201419874오류 유형: 237. Windows Media Player cannot access the file. The file might be in use, you might not have access to the computer where the file is stored, or your proxy settings might not be correct.
1733정성태8/13/201426362.NET Framework: 454. EmptyWorkingSet Win32 API를 사용하는 C# 예제파일 다운로드1
1732정성태8/13/201434479Windows: 99. INetCache 폴더가 다르게 보이는 이유
1731정성태8/11/201427081개발 환경 구성: 235. 점(.)으로 시작하는 파일명을 탐색기에서 만드는 방법
... 121  122  123  124  125  126  127  128  129  130  131  [132]  133  134  135  ...