파이써닉한 파이썬을 배워보자 - 2일차 연산자, 표현식, 데이터 조작

0

pythonic

목록 보기
2/10

연산자, 표현식, 데이터 조작

1. 리터럴

0b는 2진수, 0o는 8진수 0x는 16진수를 의미한다. bin(x), oct(x), hex(x)를 사용하면 정수를 다른 진수값으로 표현하는 문자열로 변환할 수 있다.

부동소수점은 IEEE 754를 따르며 64bit값으로 저장된다.

2, 표준 연산자

파이썬의 객체는 어떤 연산자와도 함께 동작할 수 있다. 물론 어떠한 객체들끼리 연산하냐에 따라 연산의 결과가 다를 수 있다. 가령 정수끼리의 +는 합이라면 문자열끼리의 +concat이다.

또한, go와는 다르게 서로 타입이 달라도 직관적으로 연산이 수행될 것 같으면 된다. 가령 정수와 분수는 서로 더할 수 있다.

객체 비교

동등 연산자인 ==는 x와 y값이 같은 지 평가한다.

  1. 리스트와 튜플은 서로 크기가 같고 동일한 요소가 같은 순서로 있으면 동등하다고 평가한다.
a = [1,2,3,4] # 기준
b = [1,2,3,4] # 같음
c = [1,2,4,3] # 다른 순서
d = [1,2,3,4,6] # 크기가 다름

print(a == b) # True
print(a == c) # False
print(a == d) # False
  1. dict는 x와 y에 동일한 키 집합이 있고, 해당 키가 같은 값을 가지고 있으면 참으로 평가한다.
a = {"key1" : 2, "key2" : 4} # 기준
b = {"key1" : 2, "key2" : 4} # 같음
c = {"key1" : 2, "key3" : 4} # key값이 다름
d = {"key1" : 2, "key2" : 1} # key는 같으나 value가 다름

print( a == b ) # True
print( a == c ) # False
print( a == d ) # False
  1. set은 두 집합이 서로 같은 요소로 이루어졌으면 동등하다고 평가한다.
a = set([1, 2, 3]) # 기준
b = set([3, 1, 2]) # 동등
c = set([3, 1, 2, 4]) # 같은 요소로 이루어지지 않음
d = set([3, 1, 6]) # 같은 요소로 이루어지지 않음

print(a == b) # True
print(a == c) # False
print(a == d) # False

file과 부동 소수점 처럼 서로 호환성이 없는 타입의 객체들 끼리 비교할 때는 에러가 발생하지 않고 False를 반환한다.

하지만 타입이 다르다해서 반드시 False는 아니다. 다음과 같이 정수와 부동소수점을 비교해도 True가 나올 수 있다.

print(2 == 2.0) # True

x is yx is not y와 같은 식별 연산자는 두 변수가 메모리에 있는 동일한 객체를 가리키고 있는 지 검사한다. 일반적으로 x == y이지만 x is not y인 경우도 있다. 다음의 예가 바로 그 예이다.

a = [1,2,3]
b = [1,2,3]
print(a == b) # True
print(a is b) # False

실제로 is연산자를 사용해 객체를 비교하는 일은 드물다. 두 객체가 서로 같은 고유값(메모리에 있는 객체)를 가질 것이라는 것이 합당한 이유가 없다면 ==를 많이 사용한다.

조건 표현식

조건 표현식(conditional expression)은 if-else조건문을 간단하게 만들어준다.

if a <= b:
    minvalue = a
else:
    minvalue = b

이 코드를 조건 표현식으로 사용하여 다음과 같이 줄여 쓸 수 있다.

minvalue = a if a <= b else b

a if a <= b else b로 쓰고 여기서 if문의 조건에 따라 ab냐가 정해진다. 정해지면 minvalue에 대입이 된다.

반복 가능한 연산

반복(Iteration)은 파이썬 컨테이너(리스트, 튜플, 사전 등) 파일뿐만 아니라 제너레이터(generator)에서도 모두 지원되는 파이썬의 중요한 기능이다. 반복을 지원하는 객체 s는 아래의 연산을 적용할 수 있다.

  • 반복 가능한 객체의 연산
    |연산 | 설명|
    |-----------|--------|
    |for vars in s: |반복|
    |v1, v2, ... = s|변수 언패킹(unpacking)|
    |x in s, x not in s| 맴버 검사|
    |[a, *s, b], (a, s, b) , {a, s, b} | 리스트, 튜플, 집합 리터럴에서의 확장(expansion)|
    반복 가능한 객체인 Iteration에서 필수 연산은 for loop이다. 이는 값을 하나씩 순회하는 방법이다. 다른 연산은 모두 이를 기반으로 하고 있다.

in, not in구문은 iterable한 객체 s가 객체 x를 포함하도 있는 지 여부를 in연산자를 사용하여 검사할 수 있다. 문자열이라면 innot in 연산자는 부분 문자열을 찾을 때 적용할 수 있다. 'hello' in 'hello world'True가 된다. in연산자는 와일드카드 또는 어떤 형태의 패턴 매칭(pattern matching)도 지원하지 않는다.

iterable한 객체는 언패킹을 할 수 있는데 언패킹 할 때 몇 개의 원소가 있는 지 알 수 없으니 다음과 같이 별표 변수(starred variable) 또는 에스테리스크 변수(asterisk variable)을 포함하는 언패킹의 확장 형식을 사용할 수 있다.

items = [1,2,3,4,5]
a, b, *extra = items
print(a, b, extra) # 1 2 [3, 4, 5]
*extra, a, b = items
print(a, b, extra) # 4 5 [1, 2, 3]
a, *extra, b = items
print(a, b, extra) # 1 5 [2, 3, 4]

위 예제에서 *extra는 나머지 항목들을 언패킹한다. 이 값은 항상 list이다. 물론 에스테리스크 변수를 두 번 이상 사용할 수는 없다. 왜냐하면 각 변수에 해당하는 원소들을 언패킹한 후 나머지를 에스테리스크 변수로 할당하는 것이기 때문이다.

에스테리스크 변수는 list에 쓰이면 확장을 하게해주는데 이를 스플래팅(splatting)이라고 한다.

items = [1,2,3,4,5]
a = [10, *items, 11]
b = [*items, 100, *items]
print(a) # [10, 1, 2, 3, 4, 5, 11]
print(b) # [1, 2, 3, 4, 5, 100, 1, 2, 3, 4, 5]

하지만 파일이나 제너레이터 같은 반복 가능한 객체의 대다수는 일회성만 반복을 지원한다. 즉 *로 반복 가능한 객체를 확장하면 내용은 소진되며 후속 반복에서는 더 이상 값을 생성하지 않는다.

  • 반복 가능한 객체를 입력으로 받는 함수
    |함수 | 설명 |
    |-------------------------|------------------|
    |list(s)| s로부터 리스트 생성|
    |tuple(s)| s로부터 튜플 생성|
    |set(s)| s로부터 집합 생성|
    |min(s, [,key])| s에 있는 가장 작은 항목|
    |max(s, [,key])| s에 있는 가장 큰 항목|
    |any(s)| s에 속한 항목 중 하나라도 참이면 True를 반환|
    |all(s)|s에 속한 항목이 모두 True이면 True반환|
    |sum(s, [, initial])|옵션인 초깃값고 ㅏ함께 항목을 모두 합한 값|
    |sroted(s, [, key])| 정렬된 리스트를 생성|

sequence에 대한 연산

시퀸스는 크기를 가지며 0부터 시작하는 정수 인덱스로 항목에 접근할 수 있는 반복 가능한 컨테이너이다. 문자열, 리스트, 튜플이 시퀸스에 포함된다. 시퀸스는 반복(iterable)과 관련된 모든 연산에서 다음이 추가된다.

  • 시퀸스에 적용 가능한 연산
    |연산|설명|
    |------------------|--------------|
    |s + r | 연결|
    |s n , n s | s에 대한 n개의 복사본을 생성, n은 정수|
    |s[i] | 인덱스 |
    |s[i:j]| 슬라이싱 |
    |s[i:j:stride]|확장 슬라이싱|
    |len(s)| 길이|

참고로 s*n은 n개의 시퀸스를 복사 생성하는데, 생성된 복사본은 단지 참조형태로 요소(element)를 복사하는 얕은 복사이다.

a = [3,4,5]
b = [a] # 요소는 a가 된다.
c = 4 * b
print(c)
a[0] = -99 
print(c)

b는 a의 list를 요소로 참조하고 있다. c는 4 * b로 b의 요소를 4번 반복한 리스트로 만들어진다. 문제는 c에서 복사한 요소들은 b에서 참조하고 있는 요소와 같다.

c 요소 ---> b 요소 ---> a 메모리

그래서 a리스트의 요소를 바꾸면 b와 c가 영향을 받는 것이다.

이 문제를 해결하려면 a의 내용을 복사해 시퀸스를 직접 생성하는 방법이 있다.

a = [3, 4, 5]
c = [list(a) for _ in range(4)]
print(c) # [[3, 4, 5], [3, 4, 5], [3, 4, 5], [3, 4, 5]]
a[1] = -999
print(c) #[[3, 4, 5], [3, 4, 5], [3, 4, 5], [3, 4, 5]]

변경 가능한(mutable)한 시퀸스에 대한 연산

문자열과 튜플은 변경 불가능(immutable)한 객체이므로 한 차례 생성하면 수정할 수 없다.

리스트나 다른 변경 가능한 시퀸스의 내용은 아래의 연산을 이용할 수 있다.

  • 변경 가능한 시퀸스에 적용할 수 있는 연산
    |연산| 설명|
    |------------|--------------|
    |s[i] = x | 항목 대입|
    |s[i:j] = r| 슬라이스 대입|
    |s[i:j:stride] = r | 확장된 슬라이스에 대입|
    |del s[i]| 항목 삭제|
    |del s[i:j] | 슬라이스 삭제|
    |del s[i:j:stride]| 확장 슬라이스 삭제|

집합에 대한 연산

  • 집합에 적용할 수 있는 연산
    |연산|설명|
    |------------------------|------------------|
    |s | r | s와 t의 합집합|
    | s & t | s와 t의 교집합|
    | s - t | 차집합(s에 있고 t에 없는 항목) |
    | s ^ t | 대칭 차집합(s와 t 모두 없는 항목) |
    | len(s) | 집합의 항목 개수 |
    | item in s, item not in s | 맴버 검사 |
    | s.add(item) | item을 집합 s에 추가 |
    | s.remove(item) | 집합 s에서 item을 제거, s에 item이 없으면 에러가 발생 |
    | s.discard(item) | 집합 s에서 item을 제거, s에 item이 없어도 아무런 일도 발생하지 않는다|

참고로 dict의 keyt들 역시도 set과 마찬가지인 성격을 갖기 때문에 집합 연산이 가능하다. 즉, key-view, item-view를 가진 객체에서도 동작한다는 것이다.

a = {'x' : 1 , 'y': 2, 'z': 3}
b = {'w' : 2 , 'q': 32, 'z': 3}
print(a.keys() & b.keys()) # {'z'}

다음은 두 dict의 key 중 중복되는 key를 가려내는 방법이다.

맵핑 객체의 연산

맵핑 객체는 key-value 간의 연결이며 대표적으로 dict타입이 있다. 아래의 표를 적용할 수 있다.

연산설명
x = m[k]키를 이용한 인덱스
m[k] = x키를 이용한 할당
del m[k]키를 이용한 항목 삭제
k in m키의 존재 여부 검사
len(m)매핑 객체에 들어 있는 항목 개수
m.keys()키를 모두 반환
m.values()값을 모두 반환
m.items()(key,value) 쌍으로 반환

맵핑 객체는 문자열, 숫자, 튜플 등과 같이 변경 불가능한 객체를 키값으로 사용할 수 있다. 튜플을 키로 사용할 경우, 다음과 같이 괄호를 생략하고 콤마로 구분된 값을 작성할 수 있다.

d = {}
d[1,2,3] = "foo"
d[1,0,3] = "bar"

k = {}
k[(1,2,3)] = "foo"
k[(1,0,3)] = "bar"

print(d[1,2,3]) # foo
print(k[1,2,3]) # foo

리스트, 집합, dict 컴프리헨션

리스트 컴프리헨션의 일반적인 문법은 다음과 같다.

[expression for item1 in iterable1 if condition1
            for item2 in iterable2 if condition2
            ...
            for itemN in iterableN if conditionN]

가령, 리스트의 정수를 제곱하여 새로운 리스트를 만든다면 다음과 같이 만들어야 할 것이다.

nums = [1 ,2 ,3, 4, 5]
squares = []
for n in nums:
    squares.append(n*n)

print(squares) # [1, 4, 9, 16, 25]

이를 간단하게 리스트 컴프리헨션으로 변경하면 다음과 같다.

nums = [1 ,2 ,3, 4, 5]
squares = [n * n for n in nums]
print(squares) # [1, 4, 9, 16, 25]

만약 추가적으로 여기서 n이 4이상인 값을 걸러내고 싶다면 다음과 같이하면 된다.

nums = [1 ,2 ,3, 4, 5]
squares = [n * n for n in nums if n < 4]
print(squares) # [1, 4, 9]

참고로 리스트 컴프리헨션 안에서 사용되는 변수는 모두 리스트 컴프리헨션의 내부 변수로만 사용된다. 같은 이름을 가진 다른 변수가 리스트 밖에 있어도 shadowing을 걱정할 필요가 없다.

리스트 뿐만 아니라 집합도 컴프리헨션 문법을 사용할 수 있다. 가령, 리스트에서 고유한 값들이 있는 지를 확인하고 싶다면, set으로 변경하도록 할 수 있다.

nums = [1 ,2 ,3, 4, 5, 2, 3, 1, 5, 6, 2, 0, 0, 1, 3]
num_set = {num for num in nums}
print(num_set) # {0, 1, 2, 3, 4, 5, 6}

set에서 key:value로 표시한 더 해주면 dict를 만들기 때문에 dict 컴프리헨션을 할 수 있다.

data = [
    {"key": "name", "value": "gyu"},
    {"key": "age", "value": 15},
    {"key": "address", "value": "New York"},
]
data_dict = {datum['key']:datum['value'] for datum in data}
print(data_dict) # {'name': 'gyu', 'age': 15, 'address': 'New York'}

단, 집합과 dict를 생성할 때 마지막에 들어온 값이 기존 값을 덮어쓴다는 점에 유의하자.

컴프리헨션 문장 안에서는 어떠한 종류의 예외 처리도 포함할 수 없다. 이를 위해서 함수를 예외문으로 감싸는 방법이 있다.

def toint(x):
    try:
        return int(x)
    except ValueError:
        return None

values = ['1', '2', '-4', 'n/a', '-3', '5']
data1 = [ toint(x) for x in values ]
print(data1) # [1, 2, -4, None, -3, 5]
data2 = [ toint(x) for x in values if toint(x) is not None ]
print(data2) # [1, 2, -4, -3, 5]

다음과 같이 toint라는 함수를 만들어서 컴프리헨션에서 사용하지 못하는 try-except를 이용할 수 있다.

제너레이터 표현식

제너레이터 표현식(generator expression)은 리스트 컴프리헨션과 동일한 계산을 수행하지만, 결과를 반복적으로 생성하는 객체이다. 문법은 대괄호, 대신 괄호를 사용한다는 점만 제외하면 리스트 컴프리헨션을 사용할 때와 같다.

nums = [1,2,3,4]
squares = (x*x for x in nums)

리스트 컴프리헨션과 달리 제너레이터 표현식은 실제로 리스트를 생성하거나 괄호 안의 표현식을 즉시 평가하지 않는다. 대신에 반복을 통해 필요한 때마다 값을 생성하는 제너레이터 객체를 반환한다. 결과는 다음과 같다.

nums = [1,2,3,4]
squares = (x*x for x in nums)

print(next(squares)) # 1
print(next(squares)) # 4
print(next(squares)) # 9
print(next(squares)) # 16
print(next(squares)) # #Traceback (most recent call last): ~ StopIteration

제너레이터 표현식을 for-loop에 쓰면 한 번만 사용할 수 있다. 반복을 두 번 시도하면 아무것도 없을 수 없다.

nums = [1,2,3,4]
squares = (x*x for x in nums)

for n in squares:
    print(n) # 1 4 9 16

리스트 컴프리헨션과 제너레이터 표현식에는 미세한 차이가 있다. 리스트 컴프리헨션에서 파이썬은 결과 데이터를 담은 리스트를 실제로 생성한다. 제너레이터 표현식에서는 요구에 따라 데이터를 어떻게 생성하는 지를 알고있을 뿐, 데이터를 바로 생성하진 않는다. 이는 메모리 사용 효율을 크게 높일 수 있다는 정점이 있다.

with open('data.txt') as f:
    lines = (t.strip() for t in f) # 파일을 연다
    comments = (t for t in lines if t[0] == '#') # 줄을 읽어 앞뒤 공백문자를 없앤다.
    for c in comments: # 주석 모두
        print(c)

다음의 코드를 보면 file의 라인들을 읽어와 앞 뒤 공백을 지워주는 제너레이터 lines를 만들어주었다. 만약 data.txt 파일의 크기가 100GB정도면 메모리에 모든 데이터를 올리기 힘들 것이다.

이를 해결해주는 방법이 바로 제너레이터이다. 제너레이터는 파일에서 line을 읽는 방법을 기억하지 실제로 메모리에 모든 정보를 올리지 않는다. comments 제너레이터 내부에서도 모든 lines데이터를 가져오는 것이 아니라 lines 데이터를 사용해서 데이터를 생성한다는 것을 기억할 뿐이다.

for c in comments: 부분이 실행될 때, 이제 generator가 실행된다. 다만, 한번에 모든 데이터를 가져오는 것이 아니라, 한 라인씩 데이터를 가져와 읽을 뿐이다. 때문에 100GB가 넘는 용량의 text파일이라도 메모리는 한 라인만 가져와 읽기 때문에 문제가 없다.

즉, 리스트 컴프리헨션과 달리 제너레이터 표현식은 시퀸스처럼 동작하는 객체를 생성하지 않는다는 것이다. 즉, 인덱싱 할 수 없으며, append()와 같은 보통의 리스트 연산은 동작하지 않는다. 하지만 list()를 사용하여 제너레이터 표현식을 리스트로 변환할 수 있다.

num = [ 1, 2, 3, 4]
gen_num = (n*n for n in num)
print(list(gen_num)) # [1, 4, 9, 16]

다음과 같이 사용할 수 있다.

반복문

파이썬은 내장 함수 enumerate()를 제공한다. enumerate()(인덱스, 값) 형식으로 일련의 튜플을 반환하는 iterator를 생성한다.

data = ['ew', '123', 'ewq' , 'asd']
for i,v in enumerate(data):
    print(f'index:{i} value:{v}'.format(i,v))

#index:0 value:ew
#index:1 value:123
#index:2 value:ewq
#index:3 value:asd

enumerate()함수에 start키워드 인수를 전달하여 시작값을 다르게 할 수 있다.

for i, x in enumerate(s, start=100):
    문장

루프를 순회할 때 2개 이상의 시퀸스를 동시에 반복 수행할고 싶은 경우가 있다. 가령, 반복할 때마다 다른 시퀸스에서 항목을 하나씩 가져오는 코드를 다음과 같이 만들 수 있다.

s = [1,2,3,4,5]
t = [3,12,4]
i = 0
while i < len(s) and i < len(t):
    x = s[i]
    y = t[i]
    i += 1
    print(x, y) # 1 3 , 2 12 , 3 4

다음의 코드는 굉장히 verbose하다. zip() 함수를 사용하면 반복가능한 객체 s와 t의 요소를 각각 순서대로 뽑아준다. 단, 이 둘의 len값이 서로 다르다면 짧은 것에 맞게 순회한다음 종료된다.

s = [1,2,3,4,5]
t = [3,12,4]
for x ,y in zip(s,t):
    print(x, y) # 1 3 , 2 12 , 3 4

다만, zip()의 반환값은 iterator이기 때문에 list처럼 사용할 수 없다. 이를 list로 변경하고 싶다면 list(zip(s,t))로 묶어주어야 한다.

for-else절이 가능한다. else절은 루프가 아예 실행되지 않으면 즉각적으로 실행되고,그렇지않으면 마지막 반복을 수행한 후 실행된다. 루프가 break문으로 일찍 종료되면 실행되지 않는다.

사실 for-else문을 굳이 사용할 필요는 없이 for문 내부에 flag를 넣어서 for문 외부에 if문을 두어 체크하도록 하는 것과 같으므로 자주 사용되지 않는다.

예외

예외(exception)이 발생하면 에러 메시지를 표시하고 제어 흐름에서 벗어난다. raise문으로 예외를 일으킬 수 있는데, raise Exception([value])이다. 여기서 Exception은 예외 타입을 의미한다. value는 선택할 수 있는 값으로 예외에 대한 자세한 설명을 담는다.

raise RuntimeError('Unrecoverable Error')

이 error를 잡으려면 try-except문을 사용하면된다.

예외가 발생하면 매칭되는 except절을 실행한다. 일어날 가능성이 있는 모든 예외와 try문이 일치할 필요는 없다. 일치하는 except절을 찾을 수 없으면 예외는 계속 전파되며, 실질적으로 예외를 제어할 수 있는 다른 try-except 블록에서 처리하게 된다. 프로그래밍 스타일상 코드는 실제로 복구할 수 있는 예외만 잡아야 한다. 복구 불가능한 예외는 전파되도록 두는 게 더 낫다.

만약 예외가 잡히지 않으면 프로그램의 최상위 수준까지 도달하여 인터프리터는 에러 메시지를 출력하면서 실행을 중단한다.

만약 raise문만 단독으로 쓴다면, 이는 최근에 생성된 예외가 다시 발생한다는 것이다. raise문은 이전에 발생한 예외가 처리되는 동안에만 동작한다.

try:
    file = open('foo.txt', 'rt')
except FileNotFoundError:
    print("Well, that didn't work.")
    raise # 현재 예외가 발생한다.

각각의 except 절은 as var와 같은 형식으로 지정자(modifier) as와 함께 사용할 수 있다. as 지정자는 예외가 발생하면 예외 타입 인스턴스가 있는 자리에 변수 이름을 지정해준다. isinstance()를 사용해 예외 타입을 검사할 수 있다.

예외에는 몇 가지 표준 속성이 있는데, 이는 에러에 대한 응답으로 추가 작업이 필요한 코드에서 사용하면 유용하다.

  1. e.args: 에러를 설명하는 문자열로 보통 문자열로 된 에러 메시지를 전달한다. 선택 사항으로 파일 이름을 담은 2개~3개짜리 튜플을 전달한다.
  2. e.__cause__: 예외를 처리하는 응답의 용도로 다른 예외를 의도적으로 일으켰을 때 이전 예외를 담고 있는 속성이다.
  3. e.__context__: 다른 예외를 처리하는 동안에 예외가 발생했을 때 이전 예외를 담고 있는 속성이다.
  4. e.__traceback__: 예외와 연관된 스택 역추적 객체이다.

예외값을 유지하는 데 사용되는 변수는 해당 except 블록에서만 접근할 수 있다. 제어가 이 블록을 벗어나면 변수는 정의되지 않은 변수가 된다.

다중 예외 처리 블록은 다음 예와 같이 여러 개의 except 절을 사용해 작성한다.

def doSomething():
    pass

try:
    doSomething()
except TypeError as e:
    # type error
    pass
except ValueError as e:
    # Value error
    pass

하나의 예외 절이 다음과 같이 여러 예외 타입을 처리할 수 있다.

def doSomething():
    pass

try:
    doSomething()
except (TypeError,ValueError) as e:
    pass

그냥 Exception을 사용하면 프로그램 종료 관련 예외를 제외하고는 모두 잡을 수 있다.

def doSomething():
    pass

try:
    doSomething()
except Exception as e:
    pass

예외를 모두 한 번에 잡으려 할 때는 사용자에게 에러에 관한 정확한 정보를 알려주도록 신경 써야한다.

try-except-else 문법을 지원한다. else는 반드시 except뒤에 나오며 try절에서 예외가 발생하지 않은 경우에 else가 실행된다.

try:
    file = open('foo.txt', 'rt')
except:
    print("Unable to open foo")
    data = ''
else:
    data = file.read()
    file.close()

finally문은 try-except 블록에서 일어난 일과 관계없이 꼭 실행해야 할 정리 작업을 정의한다. 즉, 에러가 나든 안나든 반드시 실행된다.

file = open('foo.txt', 'rt')
try:
    pass
    ...
finally:
    file.close()
    # 무슨 일이 있던 간에 파일을 닫는다.

finally 절은 에러를 처리할 목적으로 사용하는 것이 아니라, 에러 발생 유무와 상관없이 꼭 실행해야 하는 코드를 작성하려고 사용한다.

예외 계층 구조

파이썬에는 내장 예외(built-in exception)만 60개 이상이 있다. 표준 라이브러리까지 고려하면 수백가지로 불어난다. 게다가 코드의 어떤 부분에서 어떤 종류의 예외가 발생하는 지 미리 결정하기 힘든 경우도 있다. 예외는 함수 호출 서명(function's calling signature, 함수 시그니처)의 일부로 기록되지 않으며 코드에서 제대로 예외를 처리하는 지 확인하는 컴파일러도 없다. 그 결과 예외 처리가 무질서하게 느껴질 때가 있다.

예외는 상속을 통해 계층 구조로 되어있다. 특정 에러를 대상으로 하는 대신 좀 더 일반적인 오류 범주에 초점을 맞추는 것이 더 쉬울 수 있다. 가령 컨테이너에서 값을 가져올 때 발생할 수 있는 여러가지 오류를 살펴보자.

try:
    item = items[index]
except IndexError: # items가 sequence라면 발생
    ...
except KeyError: # items가 mapping객체라면 발생
    ...

이 코드처럼 구체적으로 두 가지 예외를 처리하는 코드를 작성하는 대신, 다음과 같이 작성하는 것이 더 쉽다.

try:
    item = items[index]
except LookupError:
    ...

LookupError는 상위 수준의 예외 그룹을 대표하는 클래스이다. IndexErrorKeyError는 모두 LookupError에서 상속되므로 except절에서 두 예외 중 하나를 잡게 된다. 그러나 LookupError는 조회와 관련 없는 예외를 포함할 만큼 광범위하지는 않다.

내장 예외의 일반 카테고리를 보여준다.

  • 예외 카테고리
    |예외 클래스 | 설명 |
    |--------------------------|--------------------------------|
    |BaseException | 예외의 루트 클래스 |
    |Exception | 모두 프로그램과 관련된 에러를 위한 기본 클래스 |
    |ArithmeticError | 모두 수학과 관련된 에러를 위한 기본 클래스 |
    |ImportError | import문과 관련된 에러를 위한 기본 클래스 |
    |LookupError | 모두 컨테이너 참조와 관련된 에러를 위한 기본 클래스 |
    |OSError | 모두 시스템과 관련된 예외를 위한 기본 클래스, IOError와 EnvironmentError는 별칭이다.|
    |ValueError | 유니코드를 포함한 값 관련 에러를 위한 기본 클래스 |
    |UnicodeError |유니코드 문자열 인코딩과 관련된 에러를 위한 기본 클래스 |

BaseException 클래스는 가능한 모든 예외에 대응되므로 예외 처리에 직접 사용하는 경우는 드물다. 이 클래스에는 SystemExit, KeyboardInterrupt, StopInteration과 같은 제어 흐름에 영향을 주는 특수 예외를 포함하고 있다. 사용자는 이런 예외를 잡는 걸 바라지 않는다.

대신 프로그램과 관련된 일반 에러는 Exception에서 모두 상속된다.

ArithmeticErrorZeroDivisionError, FloatingPointError, OverflowError와 같이 모두 수학과 관계있는 에러를 위한 기본 클래스이다.

ImportError는 모두 import와 관계있는 에러를 위한 기본 클래스이다.

LookupError는 모두 컨테이너 조회와 관계있는 에러를 위한 기본 클래스이다.

OSError는 모두 운영체제 및 환경에서 발생하는 에러를 위한 기본 클래스이다.

OSError는 파일, 네트워크 연결, 권한, 파이프, 시간 초과 등과 관련된 광범위한 예외를 포함하고 있다.

ValueError 예외는 연산에 잘못된 입력값을 제공할 때 발생한다.

UnicodeError는 유니코드 관련 인코딩 몇 디코딩 오류를 묶은 ValueError의 부분 클래스이다.

아래는 Exception에서 파생되지만 더 큰 예외 그룹의 일부가 아닌 몇몇 기본 내장 예외를 보여준다.

  • 기타 내장 예외
    |예외 클래스 | 설명 |
    |--------------------------|------------------|
    |AssertionError| assert문 실패 |
    |AttributeError | 객체에 대한 잘못된 속성 조회 |
    |EOFError| 파일의 끝 |
    |MemoryError | 회복 가능한 메모리 부족 에러 |
    |NameError | 지역 또는 전역 네임스페이스에서 이름을 찾을 수 없음 |
    |NotImplementedError | 구현 안 된 기능 |
    |RuntimeError | 일반적인 문제가 발생하였을 때의 에러 |
    |TypeError | 연산이 적절하지 않은 타입 객체에 적용되었을 대 발생|
    |UnboundLocalError | 값이 할당도 되기 전에 지역 변수가 사용되면 발생 |

예외와 제어 흐름

에러 처리 중 제어 흐름을 변경하기위해 사용되는 몇 가지 에러가 있다. 다음 아래의 예외는 BaseException에서 직접 상속받는다.

  • 제어 흐름에 사용되는 예외
    | 예외 클래스 | 설명 |
    |---------------------|-------------------|
    |SystemExit | 프로그램 종료를 나타내기 위해 발생 |
    |KeyboardInterrupt | ctrl + c를 통해 프로그램이 중단될 때 발생 |
    |StopInteration | 반복의 끝을 알려주기 위해 발생 |

SystemExit 예외는 프로그램을 의도적으로 종료할 때 사용한다. 인수로 종료 코드 또는 문자열 메시지를 제공할 수 있다. 문자열이 제공되면 sys.stderr를 통해 그 내용이 출력되고, 프로그램은 종료 코드 1과 함께 종료된다.

import sys

if len(sys.argv) != 2:
    raise SystemExit(f'Usage: {sys.argv[0]}')

KeyboardInterrupt 예외는 프로그램이 SIGINT 시그널 즉, ctrl +c를 눌렀을 때 발생하는 시그널을 받으면 발생한다. 이 예외는 비동기 식이기 때문에 약간 특이하다. 즉, 프로그램 내에서 어떤 문장이든 어떤 식으로도 발생할 수 있다. 이 예외가 발생하면 파이썬은 기본 동작을 당순히 종료하는 것 뿐이다. signal 라이브러리를 이용하여 SIGINT 시그널을 제어할 수 있다.

StopIteration예외는 반복 프로토콜의 일부이며 반복의 종료를 알린다.

새로운 예외 정의

새로운 예외 클래스를 만들기 위해서는 Exception으로부터 상속받아 새로운 클래스를 정의하면 된다.

class NetworkError(Exception):
    pass

새로 정의한 예외를 사용하기 위해서는 다음과 같이 raise문을 사용하면 된다.

raise NetworkError('Cannot find host')

예외를 발생시킬 때 raise문에 추가적인 값은 예외 클래스 생성자의 인수로 사용되낟. 이 인수는 대부분 어떤 에러 메시지를 담은 문자열이다. 하지만 사용자 예외는 다음과 같이 하나 이상의 예외값을 받도록 작성할 수 있다.

class DeviceError(Exception):
    def __init__(self, errno, msg):
        self.args = (errno, msg)
        self.errno = errno
        self.errmsg = msg

__init__()을 사용해 custom exception을 생성할 때, 위처럼 인수를 담는 튜플을 self.args 속성에 저장하는 것이 중요하다. 이 속성은 예외 역추적 메시지를 출력할 때 사용되기 때문이다.

예외는 상속을 통해 계층적으로 조직할 수 있다. 가령, 앞에서 정의한 NetworkError예외는 더 구체적인 다양한 에러를 위한 기본 클래스로 쓰일 수 있다.

class HostnameError(NetworkError):
    pass

class TimeoutError(NetworkError):
    pass

def error1():
    raise HostnameError('Unknown host')

def error2():
    raise TimeoutError('Timed out')

try:
    error1()
except NetworkError as e:
    if type(e) is HostnameError:
        #이 이 에러에 대한 특별한 작업을 수행한다.

위 코드는 except NetworkError 절에서 NetworkError에서 파생된 예외를 모두 잡는다. 발생한 예외의 구체적인 타입을 알아내려면 type()으로 예외값의 타입을 검사하면 된다.

연쇄 예외

예외를 처리할 때 종종 다른 예외를 일으키고 싶을 수 있다. 이를 위해 연쇄 예외(chained exception)을 사용한다.

class ApplicationError(Exception):
    pass

def do_something():
    x = int('N/A') # ValueError발생

def spam():
    try:
        do_something()
    except Exception as e:
        raise ApplicationError("It failed") from e

연쇄 예외를 시키고 싶다면 from으로 현재 except로 받은 예외를 써주어야한다. 그렇지않으면 raise한 예외만 반환되게 된다.

spam 함수 실행 시 다음의 에러 메시지를 받게된다.

Traceback (most recent call last):
  File "main.py", line 9, in spam
    do_something()
  File "main.py", line 5, in do_something
    x = int('N/A') # ValueError발생
ValueError: invalid literal for int() with base 10: 'N/A'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "main.py", line 13, in <module>
    spam()
  File "main.py", line 11, in spam
    raise ApplicationError("It failed") from e
__main__.ApplicationError: It failed

에러 메시지를 잘보면 두 예외를 다 포함하는 메시지를 받게 된다. 만약 위의 코드에서 from None이라고 쓰면 에러가 전파되지 않고, ApplicationError만 발생한다.

ApplicationError를 raise하게 되면 결과 예외의 __case__ 속성에 다른 예외가 포함된다.

try:
    spam()
except ApplicationError as e:
    print('It failed, Reason:', e.__cause__)

실행해보면 It failed, Reason: invalid literal for int() with base 10: 'N/A' 메시지를 받게 된다.

except 블록에 나타나는 프로그래밍 실수도 연쇄 예외를 발생시키지만, 이것은 약간 다른 방식으로 동작한다. 가령, 다음과 같이 살짝 버그가 있는 코드를 만들어 보자

def spam():
    try:
        do_something()
    except Exception as e:
        print("It failed:", err) # err가 정의되어 있지 않음

다음과 같이 except문안에 정의되지 않은 변수를 사용하여 에러를 발생시키면 다음과 같은 메시지가 나오게 된다.

Traceback (most recent call last):
  File "main.py", line 9, in spam
    do_something()
  File "main.py", line 5, in do_something
    x = int('N/A') # ValueError발생
ValueError: invalid literal for int() with base 10: 'N/A'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "main.py", line 14, in <module>
    spam()
  File "main.py", line 11, in spam
    print("It failed:", err) # err가 정의되어 있지 않음
NameError: name 'err' is not defined

출력되는 예외 추적 메시지는 이전 결과와 다르다는 것을 알 수 있다.

다른 예외를 처리하는 동안 예기치 않은 예외가 발생하면 __context__ 속성은 예외가 발생했을 때 처리하고 있던 예외 정보를 유지한다다.

try:
    spam()
except Exception as e:
    print("It fauled Reason:", e)
    if e.__context__:
        print('While handling:', e.__context__)
        print('__cause__:', e.__cause__)

아래의 출력이 나온다.

It fauled Reason: name 'err' is not defined
While handling: invalid literal for int() with base 10: 'N/A'
__cause__: None

예기치 않은 예외는 exception 그 자체로 들어오지만, 예기치 않은 예외가 발생하기 이전에 담고 있던 예외 정보는 e.__context__에 저장되는 것이다. 이때에는 e.__case__의 정보가 없다.

연쇄 예외에서 예상되는 예외와 예기치 않은 예외 사이에는 중요한 차이가 있다. 첫번째 예에서는 예상되는 예외를 도려하여 코드를 작성하였다. 가령, 코드는 명시적으로 try-except블록에 감싸져 있다.

try:
    do_something()
except Exception as e:
    raise ApplicationError("It failed") from e

두 번째 예에서 except 블록에 프로그래밍 실수가 있었다.

try:
    do_something()
except Exception as e:
    print("It failed:", err) # err가 정의되어 있지 않음

두 경우의 차이점은 미묘하지만 중요하다. 이는 연쇄 예외 정보가 __cause__또는 __context__속성에 각각 저장되는 이유이기도 하다. __cause__속성은 실패 가능성이 예상되는 경우를 위해 예약되어 있다. __context__속성은 두 경우 모두 설정되지만, 다른 예외를 처리하는 동안 발생하는 예기치 못한 예외에 대한 유일한 정보 소스가 된다.

예외 역추적

예외는 오류가 발생한 위치에 대한 정보를 제공하는 스택 역추적(stack traceback)과 관련이 되어있다. traceback은 exception의 __traceback__ 속성에 저장된다. 디버깅을 위해 traceback 메시지를 직접 생성할 수도 있다. 다음과 같이 traceback 모듈을 사용하여 역추적 메시지를 생성할 수 있다.

import traceback

class ApplicationError(Exception):
    pass

def do_something():
    x = int('N/A') # ValueError발생

def spam():
    try:
        do_something()
    except Exception as e:
        print("It failed:", err) # err가 정의되어 있지 않음

try:
    spam()
except Exception as e:
    tblines = traceback.format_exception(type(e), e, e.__traceback__)
    tbmsg = ''.join(tblines)
    print("It failed")
    print(tbmsg)

traceback.format_exception에 첫번째 안자는 exception의 타입이고 두 번째는 exception 인스턴스, 그 다음 traceback으로 출력할 msg를 넣으면 된다.

예외 처리에 대한 조언

예외 처리(exception handling)은 대규모 프로그램에서 적절히 처리하기 어려운 작업 중 하나이다. 하지만 이를 쉽게 처리할 몇 가지 방법들이 있다.

첫번째는 코드의 특정 위치에서 처리할 수 없는 예외는 잡지 않는다. 다음의 함수를 보자

def read_data(filename):
    with open(filename, 'rt') as file:
        rows =[]
        for line in file:
            row = line.split()
            rows.append(row[0], int(row[1]) , float(row[2]))
    return rows

다음의 함수를 보면 open()함수로 파일을 불러오는데, 오류가 발생할 수 있다. 이 오류를 함수 내에서 try-except문으로 잡아야하는가? 그건 아니다. 호출자가 잘못된 파일 이름을 함수로 전달하면 이를 복구할 방법은 없다. 파일을 열 수도 없고, 읽을 수도 없으며 그 외 수행할 수 있는 다른 작접도 없다.

이런 경우에는 작업이 실패하도록 두고, 예외를 호출자에게 보고하는 게 좋다. read_data()에서 에러 검사를 하지 않는다고, 예외가 어디에서도 처리되지 않는다는 것을 의미하지 않는다. 즉, read_data()의 역할은 예외를 처리하는 것이 아니다. 어쩌면 사용자에게 파일 이름을 생각나게 하는 코드가 이 예외를 처리하는데 더 큰 도움이 될 것이다.

이는 goc와 같이 특수한 오류 코드나 wrapper로 감싼 결과 타입을 사용하는 언어의 오류 처리와는 상반된다. 이러한 언어에서는 작접 전체에서 에러에 대한 반환 코드를 항상 확인하도록 세심한 주의를 기울여야 하지만, 파이썬에서는 이렇게 하지 않도록 한다. 어떤 작업이 실패하고 이를 복구하기 위해 할 수 있는 일이 없으면, 그냥 실패하도록 두는 것이 좋다. 예외는 프로그램의 상위 레벨로 전파되며 일반적으로 이를 처리하는 것은 다른 코드의 책임이다.

한 편 다음예와 같이 잘못된 데이터로부터 함수를 복구해야할 수도 있다.

def read_data(filename):
    with open(filename, 'rt') as file:
        rows =[]
        for line in file:
            row = line.split()
            try:
                rows.append(row[0], int(row[1]) , float(row[2]))
            except ValueError as e:
                print('Bad row', row)
                print('Reason:',e)
    return rows

에러를 잡을 때는 가능한 except절을 좁은 범위(narrow)로 만들자. 위 코드에서 except ValueError 대신 except Exception을 사용해 오류를 모두 잡도록 작성할 수 있다. 그러나 그렇게되면 무시해도 되는 프로그래밍 에러도 잡게된다. 코드를 그렇게 작성하지 말도록 하자. 이는 디버깅을 어렵게 만든다.

마지막으로 명시적으로 예외를 일으키는 경우에는 다음과 같이 자기 자신만의 예외 타입을 만드는 것이 좋다.

class ApplicationError(Exception):
    pass

class UnauthorizedUserError(ApplicationError):
    pass

def spam():
    ...
    raise UnauthorizedUserError('Go away')
    ...

대규모 코드 기반에서 작업할 때 어려운 문제 하나는 프로그램 실패에 대한 책임을 특정하는 것이다. 자신만의 예외를 만들면 의도적으로 일으킨 에러와 프로그래밍 실수를 더 잘 구별할 수 있다.

프로그램이 여기서 정의한 ApplicationError와 충돌하는 경우 해당 오류가 왜 발생했는 지 즉시 알 수 있다. 왜냐하면 우리가 예외 코드를 작성했기 때문이다. 반면에 프로그램이 TypeError 또는 ValueError와 같이 파이썬 내장 예외 중 하나와 충돌한다면 심각한 문제로 볼 수 있다.

컨텍스트 관리자와 with문

예외가 발생한 경우, 파일이나 lock, connection 등의 시스템 자원을 적절히 관리하는 일은 쉽지않다. 가령, 예외 발생 때문에 lock같은 중요한 자원을 해제하는 기능이 실행되지 않을 수 있다.

with 문을 사용하면 컨텍스트 관리자 역할의 객체가 제어하는 런타임 context 안에서 일련의 문자을 실행할 수 있다.

with open('debuglog', 'wt') as file:
    file.write('Debugging\n')
    ...
    file.write('Don\n')

import threading
lock = threading.Lock()
with lock:
    # critical section
    ...
    # critical section done

첫번째 예에서 with문은 제어 흐름이 블록을 벗어날 때 열린 파일을 자동으로 닫는다. 두 번째 예에서 with문은 이어서 나오는 문장 블록에 제어가 진입할 때와 빠져나올 때 자동으로 lock을 획득하고 해제한다.

with obj문은 제어 흐림이 이어서 나오는 블록에 진입하고 빠져나올 때 일어나는 일을 객체 obj가 관리하게 된다.

with obj 문이 실행되면 새로운 context에 진입한다는 신호로 obj.__enter__()가 호출된다.

제어 흐름이 context를 벗어날 때는 obj.__exit__(type, value, traceback)가 호출된다. 발생한 예외가 없으면 __exit__()의 3개 인수는 모두 None으로 설정된다. 예외가 발생하면 이 3개의 인수는 제어 흐름을 context에서 벗어나게 한 예외 타입, 예외값, traceback 정보를 담느낟.

__exit__() 메서드가 True를 반환하면 발생한 예외가 처리되었고, 더 이상 context 밖으로 전달되지 않는다는 것을 의미한다. False, None을 반환하면 발생한 예외가 컨텍스트 밖으로 전달된다.

with obj문은 추가로 as var 형식으로 지정자를 받아들인다. as지정자가 있으면 obj.__enter__() 에서 반환된 값이 var에 할당되는 것이다. 이 값은 일반적으로 obj와 동일한데 그 이유는 같은 단계에서 객체를 구성하고 context 관리자로 사용할 수 있기 때문이다. 가령, 다음의 클래스를 보도록 하자.

class Manager:
    def __init__(self, x):
        self.x = x
    def yow(self):
        pass
    def __enter__(self):
        return self
    def __exit__(self, ty, val, tb):
        pass

이 클래스와 함께 인스턴스를 생성하고 생성한 인스턴스를 context 관리자로 사용할 수 있는 것이다.

with Manager(42) as m:
    m.yow()

다음은 리스트 transaction에 대한 재밌는 코드이다.

class ListTransaction:
    def __init__(self, thelist):
        self.thelist = thelist
    
    def __enter__(self):
        self.workingcopy = list(self.thelist)
        return self.workingcopy
    
    def __exit__(self, type, value, tb):
        if type is None:
            self.thelist[:] = self.workingcopy
        return False

이 클래스는 기존 리스트에 일련의 변경 작업을 수행한다. 하지만 변경된 내용은 예외가 발생하지 않은 경우에만 리스트에 적용된다. 예외가 발생하면 원래의 리스트는 변경되지 않는다. 다음은 이 클래스를 사용한 예이다.

items = [1,2,3]
with ListTransaction(items) as working:
    working.append(4)
    working.append(5)
print(items) # [1, 2, 3, 4, 5]

try:
    with ListTransaction(items) as working:
        working.append(6)
        working.append(7)
        raise RuntimeError("We're hosed")
except RuntimeError:
    pass

print(items) # [1, 2, 3, 4, 5]

다음과 같이 예외가 발생하면 list에 발생한 모든 일들을 transcation하게 처리한다.

assert와 debug

assert문으로 프로그램에 디버깅 코드를 추가할 수 있다.

assert test [,msg]

여기서 testTrue, False로 평가되는 표현식이다. 만약 testFalse로 평가되면 assert문에 지정한 메시지인 msg와 함꼐 AssertionError예외가 발생한다.

def write_data(file, data):
    assert file, 'write_data: file not fined!'

프로그램이 올바르게 동작하기 위해, 반드시 수행되어야 할 코드에는 assert문을 사용하지 않도록 한다. 왜냐하면 파이썬이 최적화 모드(interpreter에 -o옵션을 지정)로 동작할 때는 assert문이 실행되지 않기 때문이다. 특히 사용자 입력이나 중요 연산의 성공 여부를 확인하기 위해 assert문을 쓰면 안된다.

대신 assert문은 항상 참이어야 하는 불변성을 검사할 때 사용된다. 만약 불변성을 위반하면 이는 사용자의 오류가 아니라 프로그램의 버그를 의미하는 것이다.

가령, write_data()가 최종 사용자가 사용하도록 의도된 함수라면 assert문은 일반적인 if문과 올바른 에러 처리 코드로 대체되어야 한다.

assert문의 일반적인 용도는 테스트다. 가령, 함수에 대한 최소한의 테스트를 수행할 때 사용할 수 있다.

다시 말해, assert문은 사용자의 입력을 확인하기 위함이 아니라, 내부 프로그램의 일관성을 확인하려는 것이다.

파이써닉한 파이썬

파이썬의 프로그램 실행의 기본 모델은 명령형 프로그램(imperative programming)이다. 즉 프로그램은 소스 파일에 나타나는 순서대로 하나씩 실행되는 문장들로 구성된다. 그리고 세 가지 기본 제어 흐름 구조 if, while, for만 있다. 파이썬이 어떻게 프로그램을 실행하는 지 이해하는 데 있어 모호함 같은 것은 없다.

가장 복잡하고 잠재적으로 에러가 발생하기 쉬운 기능은 exception(예외)다. exception은 자원의 적절한 관리를 엉망으로 만들 수 있다. 이 문제는 context 관리자와 with문을 사용하여 해결해야 한다.

0개의 댓글