가변 인자와 예외 문제 풀이,
filter/map복습, 정규 표현식(re),os모듈로 파일·경로 다루기.
Day.8까지 이어진 예외, 클래스, 컴프리헨션 문제 풀이를 몇 개 더 하고, 새로 정규 표현식과 os 모듈을 배웠다. 정규 표현식은 문자열에서 특정 패턴(전화번호, 이메일 등)을 찾는 도구고, os는 파일과 폴더 경로를 다루는 모듈이다.
인자를 몇 개 받을지 모를 때 *args로 받는다. (Day.3의 *args)
def find_max(*args):
if not args: # 인자가 하나도 없으면 args는 빈 튜플 ()
return None
return max(args)
print(find_max(3, 8, 2)) # 8
print(find_max(10)) # 10
print(find_max(-3, -8, -2)) # -2
print(find_max()) # None
*args는 받은 인자를 튜플로 묶는다. 인자가 없으면 빈 튜플 ()다.max()를 쓰면 ValueError: max() iterable argument is empty가 나기 때문에 먼저 검사했다.if args == ():로 비교하고 문자열 'None'을 반환했는데, 비어 있는지는 if not args로 검사하는 것이 일반적이다. (Day.2의 참/거짓 판별: 빈 튜플은 거짓) 또 문자열 'None'은 값이 없다는 뜻의 None과 다르다. 반환값을 None으로 두면 호출한 쪽에서 if result is None으로 구분할 수 있다.사려는 수량이 재고보다 많으면 OutOfStockError, 수량이 0 이하면 InvalidError를 발생시킨다.
class OutOfStockError(Exception):
pass
class InvalidError(Exception):
pass
class Product:
def __init__(self, name, stock):
self.name = name
self.stock = stock
def sell(self, qty):
if qty <= 0:
raise InvalidError("수량은 0보다 커야 한다")
if qty > self.stock:
raise OutOfStockError("재고가 부족해서 팔지 못한다")
self.stock -= qty
product = Product("노트북", 5) # 재고 5개짜리 상품
product.sell(2) # 정상 판매
print(product.stock) # 3
try:
product.sell(10) # 재고보다 많이 → OutOfStockError
except OutOfStockError as e:
print("판매실패", e) # 판매실패 재고가 부족해서 팔지 못한다
try:
product.sell(-1) # 0 이하 → InvalidError
except InvalidError as e:
print("판매실패", e) # 판매실패 수량은 0보다 커야 한다
Day.8의 은행 출금과 같은 구조다. 검사는 sell 안에서 하고, 에러 처리는 호출하는 쪽의 try/except에서 한다는 점이 포인트다. 검사를 통과하지 못하면 self.stock -= qty까지 가지 못하기 때문에 재고가 잘못 줄어들지 않는다.
19세 미만이면 AgeError를 발생시키고, 숫자가 아닌 입력도 처리하는 문제다. 처음에는 이렇게 짰다.
class AgeError(Exception):
pass
def check_age():
while True:
age = int(input("나이 입력: ")) # ← try 바깥에 있다
try:
if age < 19:
raise AgeError("19세 미만은 입장할 수 없다")
print("입장 가능~")
return age
except AgeError as e:
print(e)
except ValueError: # ← 실행될 일이 없다
print("숫자만 입력해라")
abc를 입력하면 숫자만 입력해라가 나오지 않고 ValueError로 프로그램이 그대로 종료된다. ValueError는 int(input(...))에서 나는데, 이 줄이 try 바깥에 있어서 except ValueError가 잡지 못한다. try 안으로 옮기면 된다.
def check_age():
while True:
try:
age = int(input("나이 입력: ")) # try 안으로
if age < 19:
raise AgeError("19세 미만은 입장할 수 없다")
print("입장 가능~")
return age
except AgeError as e:
print(e)
except ValueError:
print("숫자만 입력해라")
result = check_age()
print(result)
입력 abc, 15, 20을 차례로 넣어 보면 이렇게 나온다.
나이 입력: 숫자만 입력해라
나이 입력: 19세 미만은 입장할 수 없다
나이 입력: 입장 가능~
20
while True로 다시 입력받고, 성공하면 return age로 함수와 반복을 함께 끝낸다.try 안에 있어야 한다. Day.6의 나이 입력 예제와 같은 교훈이다.Song 객체를 Playlist에 담고, 전체 재생 시간과 가장 긴 곡을 구한다.
class Song:
def __init__(self, title, singer, minute):
self.title = title
self.singer = singer
self.minute = minute
class Playlist:
def __init__(self):
self.songs = [] # Song 객체를 담을 빈 리스트
def add_song(self, song):
self.songs.append(song)
def total_minute(self):
return sum(i.minute for i in self.songs)
def long_song(self):
minutes = [i.minute for i in self.songs] # [3, 3, 4, 4]
max_minute = max(minutes)
for i in self.songs:
if i.minute == max_minute:
return i.title # 처음 찾은 곡에서 바로 종료
pl = Playlist()
pl.add_song(Song("Song A", "Artist 1", 3))
pl.add_song(Song("Song B", "Artist 2", 3))
pl.add_song(Song("Song C", "Artist 3", 4))
pl.add_song(Song("Song D", "Artist 4", 4))
print("전체 재생시간 :", pl.total_minute(), "분") # 14 분
print("가장 긴 곡 :", pl.long_song()) # Song C
sum(i.minute for i in self.songs)는 Day.7의 제너레이터 표현식을 sum에 넣은 것이다. 리스트를 따로 만들지 않고 합한다.long_song은 가장 긴 시간을 먼저 구하고, 그 시간을 가진 곡을 찾아 반환한다. 가장 긴 곡이 둘 이상이면 먼저 추가된 곡만 나온다. (return이 함수를 끝내기 때문)max의 key 옵션을 쓰면 한 줄로 줄일 수 있다.def long_song(self):
return max(self.songs, key=lambda i: i.minute).title
key에 넘긴 lambda가 비교 기준을 정한다. 최댓값이 여러 개일 때 먼저 나온 것을 반환하는 것은 같다.
filter와 map 복습nums = [1, 2, 3, 4, 5, 6, 7, 8]
# 짝수만 골라내기: filter + lambda
evens = list(filter(lambda x: x % 2 == 0, nums))
print(evens) # [2, 4, 6, 8]
# 제곱한 값으로 바꾸기: map + lambda
square = list(map(lambda x: x ** 2, nums))
print(square) # [1, 4, 9, 16, 25, 36, 49, 64]
| 함수 | 역할 | 결과 개수 |
|---|---|---|
filter(함수, 리스트) | 함수가 참인 요소만 남긴다 | 줄어들 수 있다 |
map(함수, 리스트) | 모든 요소를 변환한다 | 그대로다 |
Day.4에서 정리한 대로 컴프리헨션으로도 쓸 수 있다.
[x for x in nums if x % 2 == 0] # filter와 같다
[x ** 2 for x in nums] # map과 같다
re)정규 표현식은 문자열에서 패턴을 찾는 문법이다. "010으로 시작하는 전화번호", "@가 들어간 이메일"처럼 규칙이 있는 문자열을 찾을 때 쓴다. 파이썬에서는 re 모듈로 사용한다.
| 기호 | 뜻 |
|---|---|
[a-z] | a부터 z까지 중 한 글자 |
+ | 바로 앞의 것이 1번 이상 반복 |
\d | 숫자 한 글자 ([0-9]와 같다) |
\w | 문자, 숫자, _ 한 글자 |
\s | 공백 문자 |
{3,4} | 바로 앞의 것이 3번에서 4번 반복 |
( ) | 그룹으로 묶는다 |
패턴 문자열 앞에는 r(raw string)을 붙인다. \d의 \를 파이썬이 특수 문자로 해석하지 않고 그대로 정규식에 전달하기 위해서다.
match vs search vs findallimport re
p = re.compile('[a-z]+') # 영어 소문자가 1글자 이상 이어진 것
print(p.match('python')) # <re.Match object; span=(0, 6), match='python'>
print(p.match('3 python')) # None
print(p.search('3 python')) # <re.Match object; span=(2, 8), match='python'>
print(p.search('pytho1n')) # <re.Match object; span=(0, 5), match='pytho'>
print(p.findall('pytho1n 3 python')) # ['pytho', 'n', 'python']
| 함수 | 동작 |
|---|---|
match | 문자열의 맨 앞부터 패턴과 맞는지 검사한다. 앞이 다르면 None |
search | 문자열 어디든 처음으로 맞는 곳 하나를 찾는다 |
findall | 맞는 것을 전부 찾아 리스트로 반환한다 |
'3 python'은 맨 앞이 숫자 3이라 match는 None이지만, search는 중간의 python을 찾는다.'pytho1n'에서는 1이 [a-z]가 아니라서 pytho에서 끊긴다. 이어서 찾으려면 findall을 쓰면 ['pytho', 'n']처럼 나뉘어 나온다. 필기에 "중간에 다른 문자가 끼어 있으면 끊기는 듯하다"고 적은 부분이 이것이다.None)다. 매치된 문자열은 m.group()으로 꺼낸다.import re
def phone_number(text):
pattern = r'010-\d{3,4}-\d{4}'
return re.findall(pattern, text)
text = '내 연락처는 010-1234-5678이고, 동생 연락처는 010-94-2345입니다.'
print(phone_number(text)) # ['010-1234-5678']
패턴을 읽어 보면 이렇다.
010-: 글자 그대로 010-\d{3,4}: 숫자가 3개 또는 4개-\d{4}: 하이픈 뒤 숫자 4개010-94-2345는 가운데 숫자가 2개뿐이라 패턴에 맞지 않아 결과에서 빠졌다.
search를 쓰면 첫 번째로 맞는 하나(010-1234-5678)만, match를 쓰면 맨 앞이 내라서 None이다.findall은 맞는 것이 하나도 없으면 빈 리스트 []를 돌려준다.010-12345-6789처럼 너무 긴 번호도 패턴({3,4})에 맞지 않아 걸러진다.( )괄호로 묶은 부분을 그룹이라고 한다. 매치된 전체 중 일부만 따로 꺼낼 때 쓴다.
p = re.compile(r"(\w+)\s+\d+[-]\d+[-]\d+")
m = p.search('park 010-1234-1234')
print(m) # <re.Match object; span=(0, 18), match='park 010-1234-1234'>
print(m.group(0)) # park 010-1234-1234 (매치된 전체)
print(m.group(1)) # park (첫 번째 괄호 안)
print(m.groups()) # ('park',)
group(0)은 항상 매치 전체이고, group(1)부터가 괄호 순서대로다.괄호에 이름을 붙일 수도 있다. (?P<이름>...) 형태다.
p = re.compile(r"(?P<hello>\w+)\s+((\d+)[-]\d+[-]\d+)")
m = p.search('park 010-1234-1234')
print(m.group('hello')) # park
print(m.group(1)) # park (이름 붙인 그룹도 번호가 있다)
print(m.group(2)) # 010-1234-1234 (전화번호 전체)
print(m.group(3)) # 010 (전화번호 안쪽 첫 번째 \d+)
print(m.groupdict()) # {'hello': 'park'}
그룹 번호는 여는 괄호 (가 나오는 순서대로 센다. 그래서 (\w+)가 1번, 바깥 ((\d+)...)가 2번, 그 안쪽 (\d+)가 3번이다.
필기에는 3번 그룹을 "국번"이라고 적어 두었다. 이 예제에서 3번 그룹은 전화번호의 맨 앞
010이고, 가운데 4자리(1234)가 국번이다. 번호를 매기는 규칙은 필기 설명 그대로 맞다.
def email(text):
p = re.compile(r'\w+[@]\w+[.]\w+')
m = p.match(text)
return m
print(email('test@naver.com')) # <re.Match object; ... match='test@naver.com'> (매치됨)
print(email('test-naver.com')) # None (@가 없어서 매치 안 됨)
\w+ @ \w+ . \w+ 순서로 맞는지 검사한다. 이 패턴에는 한계가 있다.
print(email('a@b.co.kr'))
# <re.Match object; span=(0, 6), match='a@b.co'> ← 'co'까지만 매치되고 '.kr'은 빠졌다
match는 앞에서부터 맞는 곳까지만 보고, 뒤에 남은 문자가 있어도 매치로 인정한다. 문자열 전체가 패턴과 맞아야 한다면 fullmatch를 쓰고, 점이 여러 번 나오는 경우도 받도록 패턴을 고친다.
print(re.fullmatch(r'\w+@\w+(\.\w+)+', 'a@b.co.kr')) # 매치됨 (전체)
print(re.fullmatch(r'\w+@\w+(\.\w+)+', 'a@b.co.kr!')) # None (뒤에 ! 때문에)
또 결과가 Match 객체나 None이라서 참/거짓으로만 쓰고 싶으면 bool(email(...))로 바꾸면 된다. 이메일 검증을 정규식으로 완벽하게 하기는 어려워서, 실무에서는 간단한 형식 검사 후 실제 인증 메일로 확인하는 경우가 많다.
print(re.sub(r'\d', '#', 'a1b22')) # a#b## (숫자를 #로 바꿈)
print(re.split(r'[,;]\s*', 'a, b;c')) # ['a', 'b', 'c'] (쉼표/세미콜론 기준으로 나눔)
Day.4의 replace, split이 고정된 문자열만 다룬다면, re.sub, re.split은 패턴으로 다룰 수 있다.
os 모듈: 파일과 경로os는 디렉터리와 파일 같은 운영체제 자원을 제어하는 모듈이다.
import os
file_path = './a.txt'
print(os.path.exists(file_path)) # 경로가 존재하는가 → True
print('파일 여부', os.path.isfile(file_path)) # 파일인가? → True
print('디렉토리 여부', os.path.isdir(file_path)) # 폴더인가? → False
exists는 파일이든 폴더든 존재하기만 하면 True고, isfile/isdir로 종류를 구분한다. 필기에서는 C:/Python/a.txt를 썼는데, 그런 파일이 내 컴퓨터에 없으면 모두 False가 나온다. 위 결과는 a.txt를 만들어 둔 폴더에서 실행한 것이다.
file_path = 'C:/Python/a.txt'
dir_name = os.path.dirname(file_path)
base_name = os.path.basename(file_path)
print('디렉토리', dir_name) # C:/Python (파일이 위치한 디렉토리)
print('파일명', base_name) # a.txt
# 경로 합치기
new_path = os.path.join(dir_name, 'backup', 'report_a.txt')
print('합친 경로', new_path) # C:/Python/backup/report_a.txt
dirname은 폴더 부분, basename은 파일 이름만 돌려준다.'폴더' + '/' + '파일'처럼 문자열로 직접 이어 붙이지 않고 os.path.join을 쓰는 이유는 운영체제마다 구분자가 다르기 때문이다. Windows는 \, 리눅스와 macOS는 /를 쓰는데 join이 알맞은 구분자를 붙여 준다.os.path.splitext('report_a.txt')를 쓴다. 결과는 ('report_a', '.txt')다.target_dir = '.' # .은 현재 작업 디렉토리
for i in os.listdir(target_dir): # 폴더 안의 이름 목록
full_path = os.path.join(target_dir, i) # 폴더 + 이름
if os.path.isfile(full_path): # 파일일 때만
size = os.path.getsize(full_path) # 파일 크기(바이트)
print(i, size)
a.txt(6바이트), b.txt(6바이트)와 sub라는 폴더가 있는 곳에서 실행하면 이렇게 나온다.
a.txt 6
b.txt 6
(sub 폴더는 isfile이 False라서 출력되지 않는다.)
os.listdir(경로)는 그 폴더 안의 이름만 리스트로 돌려준다. 파일 크기나 종류를 보려면 join으로 전체 경로를 만들어서 isfile, getsize에 넘겨야 한다.*args는 인자를 튜플로 받고, 비어 있는지는 if not args로 확인한다.try 안에 있어야 한다. int(input())이 try 밖에 있으면 ValueError가 처리되지 않는다.match(맨 앞부터), search(처음 하나), findall(전부, 리스트). 패턴은 r'...'로 쓰고, ( )로 그룹을 묶어 일부만 group(번호)로 꺼낸다. 이름 붙인 그룹은 (?P<이름>...)이다.fullmatch로 검사한다.os 모듈: exists/isfile/isdir로 확인, dirname/basename/join으로 경로를 다루고, listdir/getsize로 폴더 안을 훑는다.Tags: Python 정규표현식 re os 예외처리 filter map 컴프리헨션 개발자 학습기록