C++/카루의 C++ 강좌

[카루의 C++ 강좌] 2-3. 연산자 II, 연산자 우선순위

카루-R 2020. 3. 21. 23:45
반응형

저번 시간에 배운 연산자는 복습하셨나요? 이제 더 난이도를 올려보겠습니다.

비트 연산자

우리는 10진법을 기본으로 사용하지만, 컴퓨터는 이진법이 기본인 거 아시죠? 0과 1을 사용하죠.

_ _ _ _ _ _ _ _ 이렇게 8칸이 있으면 각 칸에서 1 또는 0을 표현할 수 있습니다. 각 칸을 비트(Bit)라고 하고 8비트가 모인 걸 1바이트(1byte = 1B)라고 합니다. 1바이트, 2바이트, 4바이트 변수가 이런 형식입니다.

각 비트의 자리수는 각각 128/64/32/16 / 8/4/2/1을 나타냅니다.

즉 이진수 0000 0011은 2와 1 자리에 비트가 있으니 2+1 = 3입니다.

이진수 0010 0010은 32와 2 자리에 비트가 있으니 32+2 = 34입니다.

unsigned char은 1바이트이고, 0부터 255까지의 값을 저장할 수 있습니다.

0 0 0 0 0 0 0 0 이렇게 모든 비트가 0이면 0이고,

0 0 0 0 0 0 0 1 오른쪽부터 채워서 이건 1,

0 0 0 0 0 0 1 0 1을 더 더하면 받아올림이 되어 이진수 10 = 십진수2가 됩니다.

이렇게 계속 채우다 보면

1 1 1 1 1 1 1 1 이건 모든 자리에 비트가 존재하므로 128+64+32+16+8+4+2+1=255입니다. 1바이트의 최대 표현 값이 255인 이유가 여기 있습니다.

2바이트인 short는 어떻게 표현할까요?

0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 이렇게 2배로 늘리면 됩니다. 1바이트가 2개니 2바이트입니다.

비트 연산자는 정수를 비트 단위로 바꾸어서 연산을 합니다 (물론 사람이 보기엔 10진수를 2진수로 바꾸는 거지만, 컴퓨터는 원래 2진수로 저장한다는 걸 생각하면..)

종류는 &, |, ~, ^, <<, >> 가 있습니다.

익숙한 게 보이시죠? &과 |는 논리 연산자에서 &&, ||으로 썼고 그 의미도 비슷합니다.

<<와 >>는 std::cout, std::cin에서 사용했지만 그 의미는 전혀 다릅니다.

우선 예제부터 보여드리겠습니다.

#include <iostream>
#include <bitset>            // std::bitset<>

int main()
{
    using _8bit = std::bitset<8>;

    int su1 = 0b0100'1011;
    int su2 = 0b0101'0110;

    std::cout << "su1 = " << su1 << std::endl;
    std::cout << "su2 = " << su2 << std::endl;

    std::cout << "su1 (binary) = " << _8bit(su1) << std::endl;
    std::cout << "su2 (binary) = " << _8bit(su2) << std::endl;
    return 0;
}

2진수로 출력하기 위해 헤더가 필요합니다. include해 주세요. using _8bit() 이건 std::bitset<8>을 다른 이름으로 줄여서 편리하게 사용하고자 하는 것입니다. 여기는 일단 넘어갑시다.

std::cout 는 10진수를 기본으로 출력합니다. 이때 아까 만든 _8bit()를 이용해서 2진수로 바꿀 수 있습니다. 그냥 함수라고 생각해도 괜찮습니다. (뭐 생성자도 함수이니...)

참고로 2진수를 변수에 대입하려면

// 4바이트 모두 표현. 일부만 해도 무방
int binary = 0B0000'0000'0000'0000'0000'0000'0000'0000;

숫자 맨 앞에 0b 또는 0B를 붙이면 2진수 표현이 가능합니다. 이때 작은따옴표 ' 를 이용해서 4자리마다 끊어주면 가독성이 올라갑니다.

그럼 이제 비트 연산자를 사용해 보겠습니다.

#include <iostream>
#include <bitset>

int main()
{
    using _16bit = std::bitset<16>;

    short su1 = 0b0100'1011;
    short su2 = 0b0101'0110;

    std::cout << "===10진수 su1, su2===" << std::endl;
    std::cout << "su1       = " << su1 << std::endl;
    std::cout << "su2       = " << su2 << std::endl;
    std::cout << "su1 & su2 = " << (su1 & su2) << std::endl;
    std::cout << "su1 | su2 = " << (su1 | su2) << std::endl;
    std::cout << "su1 ^ su2 = " << (su1 ^ su2) << std::endl;
    std::cout << "~su1      = " << ~su1 << std::endl;
    std::cout << "~su2      = " << ~su2 << std::endl << std::endl;

    std::cout << "===2진수 su1, su2===" << std::endl;
    std::cout << "su1       = " << _16bit(su1) << std::endl;
    std::cout << "su2       = " << _16bit(su2) << std::endl;
    std::cout << "su1 & su2 = " << _16bit(su1 & su2) << std::endl;
    std::cout << "su1 | su2 = " << _16bit(su1 | su2) << std::endl;
    std::cout << "su1 ^ su2 = " << _16bit(su1 ^ su2) << std::endl;
    std::cout << "~su1      = " << _16bit(~su1) << std::endl;
    std::cout << "~su2      = " << _16bit(~su2) << std::endl;
    return 0;
}
===10진수 su1, su2===
su1       = 75
su2       = 86
su1 & su2 = 66
su1 | su2 = 95
su1 ^ su2 = 29
~su1      = -76
~su2      = -87

===2진수 su1, su2===
su1       = 0000000001001011
su2       = 0000000001010110
su1 & su2 = 0000000001000010
su1 | su2 = 0000000001011111
su1 ^ su2 = 0000000000011101
~su1      = 1111111110110100
~su2      = 1111111110101001

2바이트의 short 자료형을 사용했습니다. 10진수 결과만 보면 전혀 이해가 되지 않으시겠지만, 2진수 결과를 보면 이해가 쉽습니다. 일단 ~su1의 10진수 표현이 음수가 나오는 건, signed 자료형에선 맨 앞의 비트를 +,- 부호를 판별하는 데 쓰기 때문입니다. 0이면 양수, 1이면 음수입니다. 자 일단 하나씩 살펴봅시다.

su1       = 0000000001001011    // 75
su2       = 0000000001010110    // 86
su1 & su2 = 0000000001000010    // 66

비트 연산자는 비트별로 계산을 하기 때문에 세로줄로 보시면 됩니다. &연산자(AND)는 같은 위치의 비트를 계산하여

1 & 1 일때만 결과값을 1로 내고, 나머지 경우엔 0을 냅니다. 즉, 둘 다 1일때만 1입니다.

보시다시피 su1 & su2 도 둘 다 1인 비트만 1로 계산이 된 것을 볼 수 있습니다. 이 수는 십진수로 66입니다.

기호 & 대신 bitand 라고 써도 작동합니다 (ex. su1 bitand su2)

su1       = 0000000001001011    // 75
su2       = 0000000001010110    // 86
su1 | su2 = 0000000001011111    // 95

| 연산자(OR)는 둘 중 하나라도 1이면 1이되고, 둘 다 0일때만 0이 나옵니다. su1 | su2도 | 연산에 의해서 하나라도 1인 비트는 1의 값을 가지게 됩니다. 이 수는 십진수로 95입니다.

기호 | 대신 bitor 라고 써도 작동합니다 (ex. su1 bitor su2)

su1       = 0000000001001011    // 75
su2       = 0000000001010110    // 86
su1 ^ su2 = 0000000000011101    // 29

자, 이건 헷갈리실 수도 있는데, ^연산자(XOR)는 둘 중 하나만 1이어야 1이됩니다. 둘 다 1이거나 둘 다 0이면 0입니다.

즉, 같으면 0 다르면 1입니다. 0 ^ 1 = 1, 1 ^ 1 = 0

기호 ^ 대신 xor 라고 써도 작동합니다 (ex. su1 xor su2)

su1       = 0000000001001011    // 75
~su1      = 1111111110110100    // -76

su2       = 0000000001010110    // 86
~su2      = 1111111110101001    // -87

~연산자(NOT)는 비트를 반전시킵니다. 이때 signed 변수의 경우 부호까지 바뀌게 됩니다. 맨 앞 비트는 부호를 표시하는 비트라고 말씀드렸습니다.

기호 ~ 대신 compl 이라고 써도 작동합니다 (ex. compl su1)

#include <iostream>
#include <bitset>

int main()
{
    using _16bit = std::bitset<16>;

    short su1 = 1;
    short su2 = 0b0001'0111;

    std::cout << "===10진수 su1, su2===" << std::endl;
    std::cout << "su1      = " << su1 << std::endl;
    std::cout << "su1 << 1 = " << (su1 << 1) << std::endl;
    std::cout << "su1 << 2 = " << (su1 << 2) << std::endl;
    std::cout << "su1 << 3 = " << (su1 << 3) << std::endl << std::endl;

    std::cout << "su2      = " << su2 << std::endl;
    std::cout << "su2 << 1 = " << (su2 << 1) << std::endl;
    std::cout << "su2 << 2 = " << (su2 << 2) << std::endl;
    std::cout << "su2 << 3 = " << (su2 << 3) << std::endl << std::endl;

    std::cout << "===2진수 su1, su2===" << std::endl;
    std::cout << "su1      = " << _16bit(su1) << std::endl;
    std::cout << "su1 << 1 = " << _16bit(su1 << 1) << std::endl;
    std::cout << "su1 << 2 = " << _16bit(su1 << 2) << std::endl;
    std::cout << "su1 << 3 = " << _16bit(su1 << 3) << std::endl << std::endl;

    std::cout << "su2      = " << _16bit(su2) << std::endl;
    std::cout << "su2 << 1 = " << _16bit(su2 << 1) << std::endl;
    std::cout << "su2 << 2 = " << _16bit(su2 << 2) << std::endl;
    std::cout << "su2 << 3 = " << _16bit(su2 << 3) << std::endl;
    return 0;
}
===10진수 su1, su2===
su1      = 1
su1 << 1 = 2
su1 << 2 = 4
su1 << 3 = 8

su2      = 23
su2 << 1 = 46
su2 << 2 = 92
su2 << 3 = 184

===2진수 su1, su2===
su1      = 0000000000000001
su1 << 1 = 0000000000000010
su1 << 2 = 0000000000000100
su1 << 3 = 0000000000001000

su2      = 0000000000010111
su2 << 1 = 0000000000101110
su2 << 2 = 0000000001011100
su2 << 3 = 0000000010111000

std::cout의 << 연산자와 su1, su2의 << 연산자랑 모양이 같아 헷갈리시죠? 괄호만 잘 씌워 주시면 됩니다.

<<, >>는 비트 쉬프트 연산자입니다. 비트 값을 n만큼 각각 왼쪽, 오른쪽으로 쉬프트 하는 연산자입니다.

말 그대로 비트를 옮기는 것이기 때문에 <<연산자의 경우 1을 계속 연산하면

$1,\ 2,\ 2^2,\ 2^3,\ 2^4,\ ...

이렇게 증가하는 것을 확인할 수 있습니다. 2진수 출력 결과를 보면 1인 비트가 계속 왼쪽으로 이동하는 걸 볼 수 있습니다.

나머지 수들에 대해서도 비트 전체를 왼쪽으로 쉬프트 하기 때문에 비트가 옮겨지는 모습을 볼 수 있습니다.

>> 연산자는 <<의 반대 연산자입니다.

참고로 비트연산자도 복합대입연사자로 사용할 수 있습니다. &=, |=, ^=, <<=, >>=로 쓰면 됩니다.

&=는 and_eq, |=는 or_eq, ^=는 xor_eq 로 써도 작동합니다.

형 변환 연산자

아래처럼 하면 오류는 아니지만 경고가 발생합니다.

long long ll{4537534546734557676LL};
int n = ll;
C4244: '초기화 중': 'long long'에서 'int'로 변환하면서 데이터가 손실될 수 있습니다.

long long은 8바이트고, int는 4바이트입니다. 값이 잘리는 상황이 발생하기도 하죠.

그런데 개발자가 의도한 동작일 수도 있습니다. 이때는 명시적으로 바꾼다고 컴파일러에게 알려야 합니다.

그럴 때 사용하는 연산자가 형 변환 연산자, static_cast<> 입니다.

static_cast<변환할 타입>(변환할 값)

즉, 아래처럼 형 변환을 해 주면 경고가 뜨지 않습니다.

long long ll{4537534546734557676LL};
int n = static_cast<int>(ll);

다만 이 형변환은 '안전한' 경우에만 가능합니다. 형변환이 안 되는 타입은 변환할 수 없습니다.

int n = 30;
std::string s = static_cast<std::string>(n);

정수형 변수는 문자열로 '강제' 변환할 수 없습니다. 만약 정수값을 문자열로 변환시키고 싶다면 다른 함수를 사용해야 합니다.

std::string s = std::to_string(n); // #include <string>을 해 주세요.

sizeof 연산자

이 연산자는 자료형의 크기 또는 변수의 크기를 재어 알려줍니다.

sizeof(자료형) // 괄호가 반드시 필요함
sizeof 변수    // 괄호 필요 없음

예제 코드를 봅시다.

std::cout << "char형의 크기: " << sizeof(char) << std::endl;
std::cout << "short형의 크기: " << sizeof(short) << std::endl;

int n;
std::cout << "int형 변수 n의 크기: " << sizeof n << std::endl;
std::cout << "3.14의 크기: " << sizeof 3.14 << std::endl;
std::cout << "std::string형의 크기: " << sizeof(std::string) << std::endl;
char형의 크기: 1
short형의 크기: 2
int형 변수 n의 크기: 4
3.14의 크기: 8
std::string형의 크기: 24

std::string은 좀 유난히 크죠? 아무튼, 자료형의 크기를 측정할 때는 괄호가 필수이고, 변수나 상수는 괄호가 선택입니다. 참고로 이 연산자는 sizeof...(param) 의 형태로도 쓰이는데, 이건 좀 나중에, 9-5에서 배울겁니다.

typeid

그러면 안 되겠지만, 선언해놓고 '이게 무슨 변수더라?'하는 경우가 생길 수 있습니다. 그럴 때 변수의 자료형을 출력해주는 연산자가 이 typeid입니다. 변수 뿐만 아니라 상수, 함수, 그리고 나중에 배울 클래스, 열거형, 구조체, 람다식, 펑터, 템플릿 등... 하여튼 모든 것을 알고 있는 연산자입니다.

int num;
std::cout << typeid(num).name() << std::endl;
std::cout << typeid(main).name() << std::endl;
int
int __cdecl(void)

단항 연산자

1 + 2, 2 - 3 이렇게 쓰면 +와 - 는 각각 덧셈, 뺄셈 연산자입니다.

그런데, +1, -3 이렇게 쓸 수도 있습니다. 이건 단항 덧셈, 단항 뺄셈이라고 부릅니다.

이게 변수로 가면 골치가 약간 아픕니다.

단항 + 연산자 (프로모션 연산자)

char ch; short s;
auto ach = +ch;
auto as = +s;

std::cout << typeid(ach).name() << std::endl;    // int 가 출력
std::cout << typeid(as).name() << std::endl;     // int 가 출력

정수 계열 변수에 + 연산자를 붙이면 프로모션이 되어 int보다 작은 변수들은 int로 변환한 값을 반환합니다.

auto 변수들에 int형이 담기는 이유입니다.

단항 - 연산자 (부호 반전 연산자)

int n{5};
std::cout << -n << std::endl;       // -5가 출력

부호를 반전시키는 연산자입니다. 참고로 unsigned int 자료형에 이 연산자를 적용할 경우 의도치 않은 값이 나올 수 있으니 주의하세요.

함수 호출 연산자

std::printf("%d", 10);

여기서 소괄호 ()도 연산자입니다. 함수를 호출하는 역할을 합니다.

멤버 접근, 네임스페이스

. -> ::

이들은 선언된 멤버에 접근하거나 네임스페이스에 접근할 때 사용합니다.

네임스페이스에 관한 자세한 내용은 4단원에서 하겠습니다.

기타

5단원에서 배울 동적 할당을 위한 new와 delete 연산자, 구조체를 위한 alignof 연산자 및 noexcept도 연산자입니다.

쉼표도 연산자입니다. 쉼표는 앞의 식과 뒤의 식을 묶어주며, 뒤의 식을 결과로 반환합니다.

즉, 1,2는 2입니다. 그냥 무조건 뒤에 있는 게 결과값입니다. 함수를 호출하거나 매개변수 리스트를 작성할 때도 사용합니다. std::printf() 기억하시죠?

연산자 우선순위

수학에서 곱셈을 먼저 계산하고 덧셈을 계산하듯, C++에서도 다양한 연산자가 만나면 우선순위가 있습니다.

아래는 연산자들의 우선순위를 정리한 표입니다.

우선순위

연산자

사용

결합 순서

1

::

범위 지정 연산자

왼쪽에서 오른쪽

2

a++ a−−

후위 증감 연산자

type() type{}

Functional Cast

a()

함수 호출

a[]

배열

. ->

멤버 접근

3

++a −−a

전위 증감 연산자

오른쪽에서 왼쪽

+a −a

단항 덧셈, 단항 뺄셈

! ~

논리 NOT, 비트 NOT

(type)

C 스타일 캐스팅

*a

역참조

&a

주소 연산자

sizeof

sizeof 연산자

co_await

C++20 await 표현식

new new[]

동적 메모리 할당

delete delete[]

동적 메모리 반환

4

.* ->*

멤버 포인터

왼쪽에서 오른쪽

5

a*b a/b a%b

곱셈, 나눗셈, 나머지

6

a+b a−b

덧셈, 뺄셈

7

<< >>

비트 쉬프트 연산자

8

<=>

Three-way 비교 연산자 (C++20)

9

< <= > >=

관계연산자 <, ≤, >, ≥

10

== !=

관계연산자 =, ≠

11

&

비트 AND

12

^

비트 XOR

13

|

비트 OR

14

&&

논리곱(AND)

15

||

논리합(OR)

16

a ? b : c

삼항 조건 연산자

오른쪽에서 왼쪽

throw

throw 연산자

co_yield

yield 표현식 (C++20)

=

대입연산자

+= −= *= /= %=

복합 대입 연산자 (산술)

<<= >>= &= ^= |=

복합 대입 연산자(비트)

17

,

쉼표 연산자

왼쪽에서 오른쪽

 참고로, sizeof (int) * p는 (sizeof(int)) * p로 인식됩니다.
삼항 연산자 a ? b : c;에서 b는 연산자 우선순위에 관계없이 괄호 안에 있는 것과 동일한 효과를 받습니다.
즉, a ? (b) : c; 로 인식됩니다.

 

2-3. 도전 과제

1. 다음 코드에서 출력되는 값은 무엇입니까?

int n = 5;
std::cout << n++ << std::endl;

2. 다음 코드는 경고가 발생합니다. 이를 해결하기 위한 연산자는 무엇입니까?

double d = 6.2458;
int su{d};

3. 다음 코드는 오류가 발생합니다. 이유가 무엇인가요? (HINT: 연산자 우선순위)

std::cout << true || false << std::endl;

4. [고난이도] 비트 연산자를 사용하여 홀수와 짝수를 판별하시오.

int n;
std::cin >> n;

std::cout << (/* 이 부분에 알맞은 식은? */ ? "홀수" : "짝수");

5. [고난이도] 비트 연산자를 사용하여 2의 제곱수를 판별하시오.

int n;
std::cin >> n;

std::cout << ( /* 이 부분에 알맞은 식은? */ ? "2의 제곱수입니다" : "2의 제곱수가 아닙니다");

비트연산자가 상당히 어렵죠? 사실 이 연산자들은 산술만큼 자주 쓰이진 않아요. 안심해도 됩니다.

연산자까지 열심히 학습하시느라 정말 수고 많으셨습니다. 다음 시간에는 조금 쉬어가는 느낌으로 변수의 다양한 초기화 방법에 대해 알아봅시다.

반응형