[파이썬 튜토리얼] 산술 연산자

PlanB·2022년 9월 27일
3

파이썬 튜토리얼

목록 보기
11/21

Level 1

이번 단원에서는 사칙연산의 범위에 있는 산술 연산자(arithmetic operator)에 대해 알아본다.

산술 연산자

정해진 특수문자를 통해 덧셈, 뺄셈, 곱셈, 나눗셈을 표현할 수 있다. 곱셈에 asterisk(*) 기호, 나눗셈에 slash(/) 기호를 사용한다.

print(3 + 2)
print(3 - 2)
print(3 * 2)
print(3 / 2)

결과

5
1
6
1.5

a + b는 a와 b의 합, a - b는 a에서 b를 뺀 값, a * b는 a와 b의 곱, a / b는 a에서 b를 나눈 몫으로 평가된다. 여기에 더해 나눗셈의 나머지, 소수부를 버리는 나눗셈, 거듭제곱을 수행하는 연산자도 있다.

print(3 % 2)
print(3 // 2)
print(3 ** 2)

결과

1
1
9

소수부를 버리는 나누기(//)는 반올림을 신경쓰지 않는다는 점을 주의하도록 하자. 예로 5를 3으로 나눈 결과는 1.666...이므로 반올림하면 2가 되지만, 5 // 3의 결과는 1이 된다.

복합 대입 연산자(Augmented assignment operator)

복합 대입 연산자는 연산대입을 한 번에 할 수 있게 만든다. 먼저 다음 예제는 복합 대입 연산자 없이 작성된 것이다.

a = 3
a = a + 5
print(a)

결과

8

a = a + 5 부분은 복합 대입 연산자를 사용해 다음처럼 축약할 수 있다.

a = 3
a += 5
print(a)

결과

8

연산자 뒤에 =를 붙이고, 피연산자를 명시하면 된다.

a = 3
a -= 1
print(a)

b = 3
b **= 3
print(b)

c = 3
c //= 2
print(c)

결과

2
27
1

항이 3개 이상인 산술 연산

print(3 + 2 - 5)

결과

0

한 번에 여러 연산이 적용되어야 하는 경우, 연산자의 우선순위에 따라 연산이 진행된다. 다음은 이번 단원에서 배운 산술 연산자들 간의 우선순위다.

  • 1순위 : 거듭제곱 연산(**)
  • 2순위 : 곱셈 연산(*)과 나눗셈 관련 연산들(/, //, %)
  • 3순위 : 덧셈 연산(+)과 뺄셈 연산(-)

우선순위가 동일하다면, 왼쪽에서 오른쪽 방향으로 진행한다. 소괄호를 통해 특정 부분의 우선순위를 높일 수도 있다. 소괄호로 감싸진 부분은 가장 높은 우선순위를 가진다.

print(2 * (5 - 2 * 2) ** 3 % 3)

결과

0

예제의 식 2 * (5 - 2 * 2) ** 3 % 3이 풀이되는 순서는 다음과 같다.

  1. 2 * (5 - 2 * 2) ** 3 % 3
  2. 2 * (5 - 4) ** 3 % 3
  3. 2 * 1 ** 3 % 3
  4. 2 ** 3 % 3
  5. 8 % 3
  6. 2

소괄호가 중첩되어 있는 경우, 더 안쪽의 소괄호가 더 높은 우선순위를 갖는다.

print(2 ** ((4 - 2) % 3))

결과

4

예제의 식 2 ** ((4 - 2) % 3)이 풀이되는 순서는 다음과 같다.

  1. 2 ** ((4 - 2) % 3)
  2. 2 ** (2 % 3)
  3. 2 ** 2
  4. 4

연습문제

결과 예상하기

다음 코드의 실행 결과를 예상해보자.

print(1 + 2)
print(2 - 3)
print(2 * 5)
print(3 / 2)

결과 예상하기 2

다음 코드의 실행 결과를 예상해보자.

print(7 % 3)
print(6 // 4)
print(2 ** 3)

결과 예상하기 3

다음 코드의 실행 결과를 예상해보자.

a = 3

a += 5
a *= 2
a -= 3

print(a)

결과 예상하기 4

다음 코드의 실행 결과를 예상해보자.

print(4 + 3 * 2 ** 2)

결과 예상하기 5

다음 코드의 실행 결과를 예상해보자.

print((4 + 3) * 2 ** 2)

자투리 지식

나누기 연산의 명칭

소수부를 남기는 나눗셈 연산(/)은 floating point division, 소수부를 버리는 나눗셈 연산(//)은 floor division이라고 부른다.

Level 2

우선순위에 따라 공백 사용하기

PEP 8에서는 식에 사용된 연산자들의 우선순위가 서로 다른 경우, 가장 낮은 우선순위를 가진 연산자의 주위에 공백을 추가할 것을 권고한다. 예로 다음 식에서 가장 낮은 우선순위를 가진 연산자는 -+이므로, 연산자의 앞뒤에 각각 공백을 추가했다.

x = 2*2 - 1 + 3/2

증감 연산자(Increment and decrement operators)

증감 연산자a에 1을 더하기를 위해 a += 1 대신 a++로 표현할 수 있도록 해주는 기능이다. 파이썬에는 증감 연산자 개념이 없지만, C언어에서처럼 동작한다고 치고 결과를 꾸몄다.

a = 3
print(++a)
print(a--)
print(a)

결과

4
4
3

증감 연산자에 대한 배경지식이 없다면 이해하기 어려운 결과다. ++a실행문의 시작 전에 a의 값을 1 증가한다는 의미이고, a--실행문의 종료 후에 a의 값을 1 감소한다는 것을 의미하기 때문에 이러한 결과가 나온 것이다. 이처럼 실행문의 실행 전이나 후에 적용되는 연산은 코드를 읽는 데에 혼란을 준다. 그래서 파이썬은 증감 연산자를 지원하지 않는다.

그런데 다음 코드는 에러 없이 정상적으로 동작해서, 마치 파이썬이 증감 연산자를 지원하는 것처럼 느끼게 만든다.

a = 3
++a
--a

그러나 ++, --는 증감 연산자의 의미로 해석되지 않는다. 단지 +- 연산자를 연달아 두 번 사용한 것일 뿐이다. ++a+(+a)와 같고, --a-(-a)와 같다고 보면 된다.

Level 3

산술 연산 결과의 타입 : 정수와 실수 간의 연산

산술 연산의 피연산자에 실수가 포함되어 있다면, 연산 결과도 실수 타입이 된다.

print(1.6 + 0.4)
print(1.6 - 0.6)
print(3 * 2.0)
print(4 / 2.0)
print(4 % 3.0)
print(3 // 2.0)
print(3 ** 2.0)

결과

2.0
1.0
6.0
2.0
1.0
1.0
9.0

나누기 연산은 예외가 조금 있다. 정수끼리 연산하더라도, 결과는 실수 타입이 된다. 나누어 떨어짐의 여부에 구애받지 않는다.

print(3 / 1)

결과

3.0

산술 연산 결과의 타입 : 복소수가 포함된 연산

피연산자에 복소수가 포함되어 있는 경우, 결과도 복소수 타입이 된다. 복소수는 기본적으로 실수부에서 무의미한 소수부를 제외(e.g. 2.0j2j가 됨)하기 때문에, 이 특징이 산술 연산에도 적용된다.

print((4+4j) / 2)
print(4 / (2+2j))

결과

(2+2j)
(1-1j)

조금 복잡했던 Python 2의 나누기 연산

floor division(//)은 Python 3에서 등장했다. Python 2에서는 / 연산자가 floating point division과 floor division을 모두 할 수 있어야 했기 때문에, 조금 복잡했다. 다음은 Python 2의 예다.

print(3 / 2)
print(3 / 2.0)

결과

1
1.5

피연산자에 정수만 있으면 floor division, 실수가 포함되면 floating point division이 된다.

pow와 math.pow 함수

거듭제곱 연산을 위해 거듭제곱 연산자로 power operator(**)를 사용할 수 있지만, 빌트인으로 제공되는 pow 함수나 math 모듈의 pow 함수도 같은 효과를 낸다.

import math

print(3 ** 5)
print(pow(3, 5))
print(math.pow(3, 5))

결과

243
243
243.0

이 세 가지 방식의 속도를 비교해 보자. 비교를 공평하게 하기 위해 두 가지의 규칙에 따라 벤치마크 코드를 작성했다.

  • 피연산자 모두 상수를 사용하는 경우 constant folding에 의해 ** 연산이 빠를 수밖에 없으므로 변수를 사용한다.
  • math.pow 함수는 입력의 타입에 관계 없이 float 타입의 결과를 제공한다. 따라서 math.pow 방식에는 3을, 나머지 두 가지 방식에는 3.0을 거듭제곱의 밑으로 사용한다.
from timeit import timeit

print(timeit('3.0 ** j', setup='j = 5'))
print(timeit('pow(3.0, j)', setup='j = 5'))
print(timeit('math.pow(3.0, j)', setup='import math; j = 5'))

결과

0.1281325
0.1636159
0.1463444

눈에 띌 정도로 큰 차이가 보이지는 않는다. 다른 두 방법에 비해 power operator가 사용된 코드가 더 직관적이므로, powmath.pow 함수가 사용되는 경우는 드물다.

그러나 powmath.pow가 특별하게 빠른 경우가 있는데, 여기서의 속도 차이는 산술 연산자 대신 굳이 이 두 함수를 사용할 가치가 있는 수준이다.

pow

pow(a, b)a b와 동일한 연산을 일으킨다. 따라서, 단순 거듭제곱 연산에서는 함수의 call stack에 의한 오버헤드로 인해 ____ 연산자보다 느릴 수밖에 없다. 하지만 modulo 연산을 하는 경우, a b % c처럼 작성하는 것보다 pow** 함수를 사용하는 것이 훨씬 빠르다.

pow 함수는 세 번째 positional argument를 통해 modulo 연산 기능을 제공한다. 여기에 내장되어 있는 modulo 연산 알고리즘은 양의 정수로 이루어진, 지수가 큰 거듭제곱 연산을 처리할 때 특히 빠르게 동작한다.

from timeit import timeit

print(timeit('16 ** j % 34', setup='j = 512'))
print(timeit('pow(16, j, 34)', setup='j = 512'))

결과

2.2679771
0.6414236

16 * 512 % 34pow(16, 512, 32)가 상대적으로 서너 배 차이나는 것을 볼 수 있다. 그러나 timeit.timeit 함수가 기본적으로 statement를 100만 번 실행한다는 점을 생각해서 비교하면 고민이 조금 생긴다. 한 번만 실행했을 때의 실행 시간을 따져 보면, 각각 약 0.0000023초(2300ns)와 약 0.00000064초(640ns)로 어차피 매우 빠른 수준이다. 이런 nanosecond 단위의 속도 차이가 프로그램의 전체 속도에 영향을 끼칠 정도가 아니라면, 산술 연산자를 쓰는 편이 더 나을 수 있다. 파이썬에서는 조금의 성능 이득보다 코드의 간결함을 지향하기 때문이다.

여기에 숨겨져 있는 최적화는 Exponentiation by squaring이라는 글을 읽어보면 알 수 있다.

math.pow

거듭제곱의 피연산자 둘의 타입이 floatint 중 정해지지 않았다고 하자. 만약 둘을 대상으로 거듭제곱한 결과를 항상 float 타입으로 얻으려면, 연산 전에 둘 중 하나를 float으로 캐스팅하거나, 연산의 결과를 float으로 캐스팅해야만 한다. 그러나 이렇게 연산의 중간에 타입 캐스팅을 끼워넣는 것보다, math.pow를 사용하는 것이 속도 면에서 더 유리하다.

from timeit import timeit

print(timeit('float(i) ** j', setup='i = 8; j = 15'))
print(timeit('i ** float(j)', setup='i = 8; j = 15'))
print(timeit('float(i ** j)', setup='i = 8; j = 15'))
print(timeit('math.pow(i, j)', setup='import math; i = 8; j = 15'))

결과

0.174333
0.1902502
0.3419679
0.1577260
profile
백엔드를 주로 다룹니다. 최고가 될 수 없는 주제로는 글을 쓰지 않습니다.

0개의 댓글