포스트

[배열의 이해] 2. lvalue, rvalue와 문자열 리터럴

이 포스팅은 이전 네이버 블로그의 해당 게시물에서 마이그레이션되었다.

본격적인 배열의 이해에 앞서, 배열 및 포인터와 관련된 여러가지 연산자의 성질을 먼저 알아볼 것이다. 그런데 이들 연산자의 성질에 대해 알아보기 전에 우리는 우선 lvaluervalue에 대해 알아야 한다.

1. 객체(object)

객체라는 용어는 보통 C++이나 Java와 같은 ‘객체 지향 프로그래밍(object-oriented programming, OOP)’ 언어에서 등장하는 개념이다. 그런데 놀랍게도 C언어에도 객체(object)라는 개념이 있다. 이것은 OOP의 객체와 완전히 동일한 의미이다.1 C언어 표준 문서에서는 객체(object)를 아래와 같이 정의하고 있다.

object : region of data storage in the execution environment, the contents of which can represent values.

즉, 객체란 실행 환경 안에서 값을 표현할 수 있는 데이터 저장 영역을 말한다. 이 의미를 자세히 살펴보자. 실행 환경(execution environment) 이란 프로그램이 실행되는 동안 프로그램이 점유하는 모든 값 저장 공간을 의미한다. 운영체제를 조금이라도 공부해 본 사람은 프로그램은 보통 운영체제에 의해 랜덤 엑세스 메모리(RAM)에 적재되어 실행된다는 것을 알 것이다. 그리고 만약 프로그램이 추가적인 메모리가 필요할 경우, 운영체제에 요구하여 이를 확보할 수 있다.2 이렇게 프로그램이 적재된 메모리와 추가적으로 확보한 메모리 모두 RAM의 일부이므로 RAM은 프로그램의 실행 환경이라 할 수 있다.3 또 프로그램은 CPU에서 실행되므로 CPU의 레지스터(register)도 역시 실행환경이라 할 수 있다. 객체는 이러한 실행환경 속에서 임의의 값(value)을 표현할 수 있는 특정한 데이터 저장 영역을 말하므로, 객체는 실행환경의 부분집합이라 볼 수 있다. 이러한 객체, 즉 데이터 저장 영역은 관찰 가능(observable)하거나 수정 가능(modifiable)하다는 특징이 있다. 한편 이런 객체의 특징은 객체에 적용된 액세스 지정자(access specifier, e.g. public, private, etc.)나 한정자(qualifier, e.g. const, etc.)에 따라 허용되거나 제한될 수 있다.

examples

1
2
3
int a = 3;
int b = 5;
char ch = 'B';

위 예시 코드를 컴파일하고 실행하면 실행환경의 어딘가를 변수 a, b, ch 가 일정 크기만큼 점유한다. 이때 점유하는 크기는 각 변수의 타입의 크기와 같다. 그리고 이들이 차지하는 메모리 공간에 각각 값 3, 5, 'B'가 기록된다. 그러므로 변수 a, b, ch가 표현하는 변수 공간은 객체이다. 한편 프로그램의 3, 5, 'B' 같은 리터럴(literal)도 프로그램이 실행되면 변수와 같이 실행환경의 어딘가를 점유한다. 프로그램이 리터럴을 표현하기 위해서는 실행환경의 어딘가를 점유해 그곳에 리터럴에 해당하는 값을 기록해 놓아야 하기 때문이다. 그러므로 리터럴도 객체이다.

지금까지 객체(object)에 대해 알아본 내용을 정리하면 다음과 같다.

  1. 값(value)이 들어 있는 공간, 즉 데이터 저장 영역을 객체(object)라고 한다.
  2. 값이 들어 있는 공간은 프로그램이 실행될 때 프로그램이 사용하는 그 어떠한 메모리 공간(RAM, register 등)도 될 수 있다.
  3. 객체는 액세스 지정자(access specifier)에 따라 관찰 가능(observable)하거나 수정 가능(modifiable)하다는 특징이 있다.
  4. 기본적으로 변수와 리터럴은 객체이다.

이제 객체(object)가 무엇인지 알아보았다. 그런데 분명 글의 도입부에서 lvalue와 rvalue를 알아야 한다면서 왜 갑자기 객체에 대해 알아본 것일까? 그것은 이들을 이해하는데 객체에 대한 개념을 알아야 하기 때문이다.


2. lvalue와 rvalue

C언어에서 표현되는 모든 값은 lvalue와 rvalue 두 가지 형태로 존재한다. lvalue와 rvalue는 각각 left-value, right-value의 준말로 우리나라 말로 각각 좌측값, 우측값이다. 언어적으로 ‘좌측’, ‘우측’과 같이 표현하려면 기준이 필요하다. 그 기준이 무엇일까? 바로 대입 연산자(=)이다.

1
2
int a;
a = 10;

위 코드를 보자. a = 10; 에서 대입 연산자의 좌측에 있는 alvalue, 우측에 있는 10rvalue라고 한다. 이처럼 lvalue와 rvalue라는 용어는 대입 연산자를 사용한 표현식에서 유래되었다. 다르게 말하면 대입 연산자의 왼쪽에 올 수 있는 것들은 lvalue, 오른쪽에 올 수 있는 것들은 rvalue라고 말할 수 있다. 제법 그럴 듯한 설명이다. 그러나 이러한 설명은 비록 이해에는 도움이 되지만 lvalue와 rvalue를 정확하게 설명해 주지는 못한다. 그러면 과연 lvalue와 rvalue는 무엇인가? 우선 lvalue에 대해 알아보자.

2.1. lvalue

lvalue는 ‘메모리 위치(memory location)를 식별(identify)할 수 있는 객체’를 표현하는 값 혹은 표현식(expression)을 말한다. 위 코드에서 변수의 이름 a는 10이 저장된 데이터 저장 영역의 메모리 위치를 식별할 수 있으므로 lvalue이다.(그래서 변수 이름 a를 식별자(identifier)라고도 한다.) 이때 객체의 ‘메모리 위치를 식별할 수 있다’는 것은 객체가 위치한 메모리 주소를 특정할 수 있다는 의미이다. 이처럼 lvalue는 객체의 위치를 표현하는 값이므로, 위치값(locator value)이라고도 한다. 그리고 lvalue가 표현하는 객체는 단일 표현식(single expression)4을 넘어서도 존재한다는 특징이 있다. 또 lvalue는 그 이름과 다르게 대입 연산자(=)의 왼쪽과 오른쪽 모두에 등장할 수 있다. 마지막으로, lvalue가 표현하는 객체는 기본적으로 관찰 가능(observable)하고 수정 가능(modifiable)하다. 여기서 ‘수정 가능하다’는 말은 대입 연산자에 의해 임의의 값으로 할당 가능(assignable)하다는 말과 같다.

lvalue에 대해 정리하면 다음과 같다.

  1. lvalue는 메모리 위치를 식별할 수 있는 객체를 표현하는 표현식이다. 위치값(locator value)라고도 한다.
  2. lvalue가 표현하는 객체는 단일 표현식(single expression)을 넘어서도 존재한다.
  3. lvalue는 그 이름과 다르게, 대입 연산자(=)의 왼쪽과 오른쪽 모두에 등장할 수 있다.
  4. lvalue(가 표현하는 객체)는 기본적으로 관찰 가능(observable)하고 수정 가능(modifiable)하다.

2.2. rvalue

앞서 C언어에서 표현되는 모든 값은 lvalue와 rvalue 두 가지 형태로 존재한다고 하였다. 따라서 rvalue는 lvalue가 아닌 모든 표현식(expression)을 말한다. 구체적으로, rvalue는 ‘메모리 위치를 식별할 수 없는 객체’를 표현하는 값 혹은 표현식을 말한다. 즉 rvalue가 표현하는 객체는 분명히 메모리의 어딘가에 저장되어 있다. 그러나 그 객체는 보통 단일 표현식에서 리터럴이나 특정 연산자 등에 의해 메모리에 임시적(temporary)으로 생성된 것이며, 단일 표현식을 넘어서면 곧바로 소멸된다. 이러한 성질 때문에 rvalue가 표현하는 객체의 메모리 위치를 식별할 수 없으며, 혹여나 식별할 수 있더라도 쓸모가 없다(식별 후 곧바로 소멸하므로). 같은 이유로 rvalue가 표현하는 객체에 값을 할당하는 것도 불가능하다. 따라서 rvalue는 대입 연산자(=)의 오른쪽에서만 등장할 수 있다. 즉 우리는 rvalue가 표현하는 객체의 값만을 취할 수 있는 것이다. 이러한 rvalue가 표현하는 객체 성질을 다르게 표현하면, rvalue가 표현하는 객체는 모두 관찰 가능(observable)하고 수정 가능하지 않다(non-modifiable).

rvalue에 대해 정리하면 아래와 같다.

  1. rvalue는 lvalue가 아닌 것으로, 메모리 위치를 식별할 수 없는 객체를 표현하는 표현식이다.
  2. rvalue가 표현하는 객체는 단일 표현식(single expression)을 넘어서면 소멸한다.
  3. rvalue는 대입 연산자(=)의 오른쪽에서만 등장할 수 있다.
  4. rvalue(가 표현하는 객체)는 모두 관찰 가능(observable)하고 수정 가능하지 않다(non-modifiable).

examples

1
2
3
4
int a, b;
a = 10;
b = 3 + 5;
//2 - a = 8;  //wrong

a = 10; 은 정수형 리터럴 10을 대입 연산자(=)를 이용해 변수 a에 할당(assign)하는 표현식이다. 대입 연산은 오른쪽에서 왼쪽으로 평가되므로 우선 리터럴 10의 값을 가진 임시 객체가 메모리의 어딘가에 생성된다. 그리고 임시 객체의 값이 대입 연산에 의해 a가 표현하는 객체에 할당되며, 임시 객체는 소멸된다. 이때 변수 a가 나타내는 객체는 표현식을 넘어서도 존재한다. 따라서 a는 lvalue이며 10은 rvalue이다.

b = 3 + 5; 를 보자. 대입 연산자의 오른쪽이 먼저 평가되므로 우선 값 3과 값 5를 가진 두 개의 임시 객체가 메모리 어딘가에 각각 생성된다. 이후 덧셈 연산자에 의해 값 3과 값 5를 더한 결과인 값 8을 저장하는 임시 객체가 메모리 어딘가에 생성된다. 이후 값 8을 저장하는 임시 객체의 값이 대입 연산에 의해 b가 표현하는 객체에 할당되며, 값 3, 5, 8을 저장하는 각각의 임시 객체가 모두 소멸된다. 이때 b가 나타내는 객체는 표현식을 넘어서도 존재한다. 따라서 b는 lvalue이며, 리터럴 3, 리터럴 5, 덧셈 표현식 3 + 5 의 결과는 모두 rvalue이다.

2 - a = 8; 에서 뺄셈 표현식 2 - a의 결과는 덧셈 연산자와 마찬가지로 rvalue이다. 그런데 rvalue는 수정 가능하지 않으므로 대입 연산자의 왼쪽에 올 수 없다. 따라서 2 - a = 8; 은 컴파일 오류를 발생시킨다.

위 예시에서 알 수 있듯이, 정수형 리터럴(32, -6 …), 실수형 리터럴(5.9, .3 …), 문자형 리터럴('t', '\n' …)과 같은 리터럴(literal)은 표현식에서 리터럴에 해당하는 값을 가진 임시 객체를 만든다. 따라서 대부분의 리터럴은 rvalue이다. 그리고 모든 산술 연산자(+ - / * %)와 (예시를 들지는 않았지만) 관계 연산자(== != < > …)의 결과도 rvalue이다. 마지막으로 대부분의 변수의 이름은 lvalue이다. 이를 정리하면 아래와 같다.

  1. 대부분의 리터럴(literal)는 rvalue이다.
  2. 모든 산술 연산자와 관계 연산자, 비트 연산자의 결과는 rvalue이다.
  3. 대부분의 변수의 이름은 lvalue이다.

3. 수정할 수 있는 lvalue (modifiable lvalue)

lvalue(가 표현하는 객체)는 기본적으로 관찰 가능(observable)하고 수정 가능(modifiable)하다고 했다. 그러나 모든 lvalue가 수정 가능한 것, 즉 대입 연산자의 왼쪽에 올 수 있는 것은 아니다. 이러한 lvalue를 수정 가능하지 않은 lvalue(non-modifiable lvalue)라고 한다. C언어 표준에 의하면 수정 가능하지 않은 lvalue는 다음과 같이 정의된다.

  1. 배열 형식(array type)
  2. 불완전한 형식(imcomplete type)
  3. const 한정자(qualifier)가 붙은 형식
  4. const 한정자가 붙은 형식을 멤버로 가지고 있는 구조체(struct)나 공용체(union)

따라서 배열의 이름은 정의에 의하여 대입 연산자의 왼쪽에 올 수 없고, const 한정자가 붙은 형식 역시 대입 연산자의 왼쪽에 올 수 없다.

examples

1
2
3
4
5
6
7
float giant[3][4]; 
//giant의 형식은 float [3][4] 이므로(배열 형식) 수정 가능하지 않은 lvalue
void k;  
/* void형은 불완전한 형식이므로 수정 가능하지 않은 lvalue
사실 void형 변수를 만드는 것은 불가능하다 */
const int PI = 3.14;  
//PI는 const 한정자가 붙은 int 형식이므로 수정 가능하지 않은 lvalue

수정 가능하지 않은 lvalue가 아닌 모든 lvalue를 수정할 수 있는 lvalue(modifiable lvalue)라고 한다.


4. lvalue 변환 (lvalue conversion)

​ lvalue는 이름과 다르게 대입 연산자의 왼쪽과 오른쪽 모두에 등장할 수 있다고 했다. 그런데 왜 lvalue는 이름과 다르게 대입 연산자의 오른쪽에도 등장할 수 있는 것일까? 그것은 바로 lvalue 변환(lvalue conversion)이 일어나기 때문이다. lvalue 변환이란, lvalue 표현식이 lvalue가 표시하는 객체에 저장된 값으로 변환되는 것을 말한다. 이때 변환된 값은 rvalue이므로, lvalue에서 rvalue로 변환이 일어난 것으로 볼 수 있다. 이러한 lvalue 변환은 컴파일러에 의해 수행된다.

examples

1
2
3
4
int a, b;
a = 8;
b = a;   //대입 연산자의 오른쪽에 등장한 a
printf("%d", b);

표현식 b = a; 를 보면 변수 이름 a가 대입 연산자 오른쪽에 등장한 것을 볼 수 있다. 이 코드를 컴파일하면 a는 컴파일러에 의한 lvalue 변환에 의해 a가 표시하는 객체에 저장된 값으로 변환되고 b = 8; 이라는 표현식으로 대체된다. 표현식 printf("%d", b);에서의 변수 이름 b도 마찬가지로 lvalue 변환에 의해 컴파일 이후 printf("%d", 8); 이라는 표현식으로 대체된다.

4.1. lvalue 변환의 예외

lvalue 변환은 항상 일어나는 것은 아니다. 몇 가지 예외가 있데, 그것은 아래와 같다. (즉 예외가 아닌 모든 상황에서 lvalue는 lvalue 변환이 일어난다.)

  1. lvalue가 배열 형식인 경우
  2. sizeof 연산자의 피연산자로 사용될 경우
  3. C11 표준부터 _Alignof 연산자의 피연산자로 사용될 경우
  4. 단항 &연산자의 피연산자로 사용될 경우
  5. ++나 – 연산자의 피연산자로 사용될 경우
  6. 멤버 접근 연산자 . 의 왼쪽 피연산자로 사용될 경우
  7. 대입 연산자의 왼쪽 피연산자로 사용될 경우

따라서 다음 예시에 등장하는 모든 표현식 a는 lvalue이다.

examples

1
2
3
4
5
6
sizeof(a);
&a;
a++;
a.member;
a = 8;
int* ptr = a; // a가 배열 형식인 경우

5. 문자열 리터럴(string literal)

마지막으로 문자열 리터럴(string literal)에 대해 알아본다. 문자열 리터럴은 "hello", "global"과 같이 문자의 유한 집합을 큰따옴표 두개( “ “ )로 감싼 리터럴을 의미한다. 그러면 대부분의 리터럴은 rvalue라고 하였으므로 문자열 리터럴도 rvalue일까? 아니다. 예외적으로, 문자열 리터럴은 lvalue이다. 그러므로 문자열 리터럴은 다른 리터럴과 달리 메모리 주소를 알 수 있으며, 프로그램이 실행되는 동안 소멸하지 않고 프로그램이 종료하는 순간까지 메모리를 차지한다. 그러면 문자열 리터럴은 lvalue이므로 대입 연산자의 왼쪽 피연산자로 둘 수 있을까? 그렇지 않다. 왜냐하면 문자열 리터럴의 본질은 배열 형식(array type)이기 때문이다. 이것은 sizeof 연산자의 결과를 통해 사실임을 확인할 수 있다.

1
printf("%lu\n", sizeof("hello"));

위 코드는 콘솔창에 6을 출력한다. 이것은 문자열 리터럴 "hello" 의 형식은 char [6] 이기 때문이다.5 따라서 문자열 리터럴은 수정 가능하지 않은 lvalue(배열 형식)이기 때문에 대입 연산자의 왼쪽 피연산자로 둘 수 없다.

그런데 n개의 문자로 구성된 문자열 리터럴의 형식을 다시 보면 char [n+1] 인 것을 확인할 수 있다. 그러면 배열의 원소 형식은 char이므로 다음과 같은 코드가 가능한 것일까?

1
"rotation"[2] = 'c';

아쉽게도 위 코드를 컴파일하고 실행시켜 보면 Segmentation Fault 오류가 발생하면서 프로그램이 비정상적으로 종료된다. 사실 문자열 리터럴(배열 형식)의 원소 형식이 char인 이유는 기존 C 코드와의 호환성을 위한 것으로, 문자열 리터럴은 그 형식과는 다르게 프로그램의 상수 영역에 저장되므로 원소의 값을 변경시키려고 하면 Segmentation Fault 오류6가 발생하는 것이다. 그래서 C++에서는 이러한 혼란을 방지하기 위해 n개의 문자로 구성된 스트링 리터럴의 형식을 const char [n+1]으로 정의한다. 지금까지 언급한 내용을 정리하면 아래와 같다.

  1. 문자열 리터럴(string literal)은 수정 가능하지 않은 lvalue이다.
  2. n개의 문자로 구성된 스트링 리터럴의 형식은 배열 형식으로, char [n+1] 이다.
  3. 스트링 리터럴의 원소는 수정 가능하지 않다.

참고 문서






각주

  1. 사실 C언어는 객체’지향’ 언어가 아닐 뿐이다. 

  2. malloc 계열의 함수가 이러한 기능을 수행한다. 

  3. 그러나 프로그램이 운영체제가 아닌 이상 RAM 전체가 실행 환경인 것은 아니다. 

  4. 이 글에서 단일 표현식이라는 용어는 세미콜론(;)으로 마무리 된 하나의 문장이라는 의미로 사용했다. 

  5. ​배열의 길이가 6인 것은 배열의 마지막 원소로 NULL 문자가 포함되었기 때문이다. 

  6. 프로그램이 허용되지 않은 메모리 영역(읽기 전용 메모리 등)에 접근을 시도하거나, 허용되지 않은 방법으로 메모리 영역에 접근을 시도할 경우 발생하는 오류이다. 

이 포스팅은 작성자의 CC BY-NC 4.0 라이선스를 준수합니다.