[파이썬]얕은 복사와 깊은 복사

데이터를 복사하는 방법에는 크게 얕은 복사깊은 복사가 있다. 이 둘에 대해서 어떠한 차이점이 있는지 알아보고 흔히 할 수 있는 실수에 대해서 정리해보자.

기본 개념

파이썬에서의 대입문

파이썬에서 대입문은 객체를 복사하지 않고 대상과 객체 사이에 바인딩을 만든다.

얕은 복사

새로운 객체를 생성한 후 원본에 접근할 수 있는 참조를 입력한다.

깊은 복사

새로운 객체를 생성한 후 원본의 복사본으로 데이터를 채워 넣는다.

코드로 비교를 해 보면 더 차이점이 와닿을 것 같다.

대입문을 통한 복사

1
2
3
4
5
6
7
8
a = [1, 2, 3]
b = a

id(a), id(b) # id값이 같다

b[0] = 'aaa'
a # ['aaa', 2, 3]
b # ['aaa', 2, 3]

이미 잘 알려져있는 예제이다. 위의 파이썬의 대입문에서 말했듯이 객체를 복사하지 않고 바인딩을 만들기 때문에 a와 b 변수는 같은 리스트 객체를 참조하고 있다. 따라서 둘 중 하나만 변경이 되더라도 모든 변수의 데이터에 영향을 주게 된다.

위의 문제를 해결하기 위해서 보통은 아래와 같은 방법을 사용해 복사를 수행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from copy import copy

a = [1, 2, 3]
b = a[:]
c = list(a)
d = copy(a)

id(a), id(b), id(c),id(d)
# (4590448328, 4592590408, 4592444552, 4590818120)

a[0] = 'aaa'
a # ['aaa', 2, 3]
b # [1, 2, 3]
c # [1, 2, 3]
d # [1, 2, 3]

리스트 슬라이스 / list 함수 / copy 함수를 통해 복사하면 객체의 id값이 다르기 때문에 다른 데이터에는 영향을 주지 않게 된다. 그리고 이러한 복사 방식을 얕은 복사라고 부른다. 얼핏보면 문제없어 보이지만 리스트 안에 리스트가 있을 경우에는 예기치 못한 상황이 생긴다.

얕은 복사(copy 함수)

1
2
3
4
5
6
7
8
9
a = [1, [1, 2, 3]]
b = copy(a) # 얕은 복사 수행

id(a), id(b)
# (4592590728, 4592373128)

a[1].append('aa')
a # [1, [1, 2, 3, 'aa']]
b # [1, [1, 2, 3, 'aa']]

copy 모듈의 copy 함수를 통해 변수 b를 만들고 리스트안의 리스트를 수정했더니 a, b 두 변수의 값이 변경되었다. 즉, copy 함수도 얕은 복사를 수행한다는 것이다. 원본의 값이 바뀌어도 복사된 객체에 영향을 주지 않으려면 깊은 복사(deepcopy)가 필요하다.

깊은 복사(deepcopy)

1
2
3
4
5
6
7
8
9
10
11
from copy import deepcopy

a = [1, [2, 3, 4]]
b = deepcopy(a)

id(a), id(b)
# (4592642568, 4591296456)

a[1].append('aa')
a # [1, [2, 3, 4, 'aa']]
b # [1, [2, 3, 4]]

이렇게 깊은 복사를 통해 복사를 하면 원본 데이터의 값이 변경되어도 복사본은 영향을 받지 않는다.

얕은 복사와 깊은 복사의 차이

어떠한 차이점이 있는지 정리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
# 얕은 복사
a = [1, [1, 2, 3]]
b = copy(a)
c = deepcopy(a)

id(a), id(b), id(c)
# (4592854280, 4591051528, 4594252744)
# 일단 모든 객체는 서로 다른 주소를 가진다

id(a[1]), id(b[1]), id(c[1])
# (4592595592, 4592595592, 4594187592)
# 얕은 복사한 b의 내부 리스트는 원본과 같은 주소를 가진다
# 그렇기 때문에 원본에 영향을 받는 것이다

얕은 복사과 깊은 복사 모두 객체를 복사하긴한다. 하지만 얕은 복사의 경우 저장된 데이터들까지 복사를 하진 않기때문에 서로가 영향을 주고 받으며, 완전히 독립적인 객체로 만들기 위해서는 깊은 복사가 필요한 것이다.

마지막으로 정리를 해보면,

  • 대입문을 통한 복사는 단순 복제(동일한 객체를 참조)
  • 얕은 복사는 껍데기만 복사, 내용은 동일한 객체를 참조
  • 깊은 복사는 껍데기를 복사하고 내용도 재귀적으로 복사

[참고]
https://docs.python.org/ko/3/library/copy.html
https://kongdols-room.tistory.com/67

Share

파이썬의 클로저

함수 안에 함수를 정의하는 내부함수를 통해 클로저를 구현할 수 있다. 파이썬이 함수를 일급 객체로 취급하기 때문에 가능하다. 클로저의 간단한 사전적 의미로는 “일급 객체를 지원하는 언어에서 유효 범위 이름을 바인딩하는 기술” 이라고 하는데 이해하기 복잡하니 내부함수를 반환하는 함수라고 (간단하게) 생각해도 될 것 같다.

클로저를 살펴보기에 앞서 변수의 스코프에 대해 먼저 알아보자.

클로저 이해에 필요한 스코프

1
2
3
4
5
def func1(a):
print(a)
print(b)

func1(1)

너무 간단하다. b의 값이 없기 때문에 에러가 발생한다.

1
2
3
4
5
6
b = 123
def func2(a):
print(a)
print(b)

func2(1)

함수 안에는 b 변수에 대한 값이 없으며 전역으로 선언된 b = 123을 사용하기 때문에 에러없이 함수를 호출할 수 있다.

1
2
3
4
5
6
7
b = 123
def func3(a):
print(a)
print(b)
b = 321

func3(1)

이번 예제는 주의해서 봐야한다. 일단 에러가 발생한다. 함수 안에서 변수 b를 출력하려고 하는데 값이 초기화 되기 전에 사용했기 때문에 UnboundedError가 일어난 것이다.

조금 더 설명하자면,
func3이 실행될때 런타임 상에서 변수의 존재를 체크한다. 함수 내에 변수 b가 있기 때문에 변수의 존재는 알고 있게되며 값이 할당되기 전에 출력을 먼저 하려고 해서 에러가 발생하게 된 것이다.

클로저

위의 스코프를 이해했다면 클로저의 동작 방식을 이해하는 데에도 큰 도움이 된다. 자유 변수(free variable)가 무엇인지 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
def outer():
# free variable 영역
# 클로저 영역
number_list = []
def inner(v):
number_list.append(v)
print(sum(number_list))
return inner

func = outer() # inner 함수 리턴 후 outer 함수 종료
func(1) # 1
func(2) # 3
func(3) # 6

함수 실행의 결과는 인자로 들어간 값들이 누적된 합이 출력된다. outer 함수는 inner 함수를 리턴하고 종료가 되었는데 어떻게 inner 함수는 outer 함수 영역에 존재하는 변수를 계속해서 사용할 수 있는 것일까?

number_list 변수가 자유 변수 영역에 있기 때문이다. 내부함수 입장에서 자기가 선언하지 않은 변수를 자유 변수라고 이해하면 쉬울 것 같다. 외부함수의 변수를 계속 참조할 수 있어서 값을 계속 사용할 수 있다는 장점이 있지만 남용하게 되면 메모리를 낭비할 수도 있다는 단점도 있다.

자유변수 확인하기

dir 함수를 사용한다면 객체가 가진 내부정보를 볼 수 있다. 자유 변수로 사용되는 변수가 무엇인지 확인하려면 아래와 같이 하면 된다.

1
2
print(func.__code__.co_freevars)
# ('number_list',)

자유 변수로서 사용되는 변수를 출력한다. 변수의 값을 확인하려면 이렇게 하면 된다.

1
2
print(func.__closure__[0].cell_contents)
# [1, 2, 3]

클로저 사용시 주의사항

1
2
3
4
5
6
7
8
9
10
11
12
13
def outer():
count = 0
total = 0
def inner(v):
# nonlocal count, total # 문제 해결
count += 1
total += v
print(total / count)
return inner

func = outer()
func(1)
# UnboundLocalError: local variable 'count' referenced before assignment

count 변수를 값이 할당 되기 전에 사용했기 때문에 에러가 발생한다. 여기서 중요한 점은 밖의 변수들과 inner의 변수들이 이름이 같을지라도 다른 변수라는 것이다. 문제를 해결하기 위해서 nonlocal 키워드를 사용해주어야 한다.

1
nonlocal count, total

[참고]
https://itholic.github.io/python-closure/
http://schoolofweb.net/blog/posts/%ED%8C%8C%EC%9D%B4%EC%8D%AC-%ED%81%B4%EB%A1%9C%EC%A0%80-closure/

Share

[Form]의식의 흐름대로 정리하는 장고 Form

장고의 정말 큰 장점 중 하나는 Form이라고 생각한다. 물론 여러 좋은 점들이 있지만 장고 폼을 통해서 값을 변경 / 검사를 하고 심지어 HTML 코드로도 랜더링 할 수 있기 때문인데, 장고에서 자주 사용되는 Form 패턴을 통해 어떻게 동작하는 지 정리해 보려고 한다.

아마 새로운 게시물을 작성하는 것과 같은 일에 장고 폼을 사용할 것이다.

1
2
3
4
5
6
7
8
9
def new_post(requst):
if request.method == "POST":
form = PostForm(request.POST)
if form.is_valid():
post = form.save()
return redirect(post)
else:
form = PostForm()
return render()

HTTP method에 따라 빈 폼과 데이터가 바인딩된 폼을 생성하는 함수이다. 너무 단순하고 당연하다고 생각했기 때문에 별다른 의문없이 사용해온 패턴이지만 이제는 is_valid 함수가 하는 행동이 무엇인지 고민해 볼 필요가 있단 생각에 찾아보았다.

form 생성

먼저 form 생성코드를 살펴보자. 보통 폼 클래스는 forms.Form 또는 forms.ModelForm 클래스를 상속받아 생성하기 마련이다.

1
2
3
4
5
class PostForm(forms.ModelForm):
pass

class PostForm(forms.Form):
pass

위 폼 클래스들 간의 상속관계를 보면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 폼
# BaseForm -> Form
class BaseForm:
pass

class Form(BaseForm):
pass

# 모델폼
# BaseForm -> BaseModelForm -> ModelForm
class BaseModelForm(BaseForm):
pass

class ModelForm(BaseModelForm):
pass

둘 다 BaseForm으로부터 시작되는 클래스이며 주의깊게 보아야 할 코드들은 거진 다 BaseForm에 구현되어 있다.

1
form = PostForm(request.POST)

위 코드를 통해 POST 요청의 body에 들어있는 데이터들이 PostForm 클래스에 데이터로 들어가게 되는데 BaseForm 클래스의 init 함수 인자를 보고서야 왜 그렇게 되는지 이해할 수 있었다.

생성한 폼 클래스가 데이터를 만드는 법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# django/forms/forms.py

class BaseForm:
def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
initial=None, error_class=ErrorList, label_suffix=None,
empty_permitted=False, field_order=None, use_required_attribute=None,
renderer=None):

self.is_bound = data is not None or files is not None
self.data = {} if data is None else data
self._errors = None

@property
def errors(self):
if self._errors is None:
self.full_clean()
return self._errors

request.POST의 데이터는 init 함수의 첫 번째 위치 인자로 들어가게 되고 self.data라는 인스턴스 변수의 값이 된다. 그리고 is_valid 함수를 호출하여 유효성 검사 수행한다.

is_valid 함수 호출을 통한 유효성 검사

1
2
3
4
# django/forms/forms.py

def is_valid(self):
return self.is_bound and not self.errors

데이터가 바인딩된 폼의 유효성 검사의 결과는 True / False인 부울값이다. is_bound(데이터가 바인드 되었는지) 그리고 errors(에러가 없는지)를 판단하게 되며 둘 중에 하나라도 False 값을 가진다면 유효성 검증을 통과하지 못하고 끝난다.

request.POST를 통해 데이터가 들어갔다면 is_bound는 True가 되고, errors의 값을 판단하기 위해 프로퍼티를 조회하는데 self._errors의 값이 처음에는 None이기 때문에 full_clean 함수를 호출하는 로직으로 넘어가야 한다.

full_clean 함수 호출

1
2
3
4
5
6
7
8
9
10
11
12
13
def full_clean(self):
self._errors = ErrorDict()
if not self.is_bound: # Stop further processing.
return
self.cleaned_data = {}
# If the form is permitted to be empty, and none of the form data has
# changed from the initial data, short circuit any validation.
if self.empty_permitted and not self.has_changed():
return

self._clean_fields()
self._clean_form()
self._post_clean()

크게 보면 cleaned_data를 빈 딕셔너리로 초기화하고 _clean_fields 함수와 _clean_form 함수 그리고 _post_clean 함수를 호출하는 것으로 나눌 수 있다. 우리가 뷰 함수에서 유효성 검증이 통과한 데이터를 cleaned_data[‘title’]과 같이 사용할 수 있었던 것은 여기서 cleaned_data를 만들어주었기 때문인 것도 알 수 있었다.

각 함수를 살펴보자.

full_clean 함수의 _clean_fields 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# django/forms/forms.py
def _clean_fields(self):
for name, field in self.fields.items():
if field.disabled:
value = self.get_initial_for_field(field, name)
else:
value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name))
try:
if isinstance(field, FileField):
initial = self.get_initial_for_field(field, name)
value = field.clean(value, initial)
else:
value = field.clean(value) # 필드 객체의 clean 함수 호출
self.cleaned_data[name] = value
if hasattr(self, 'clean_%s' % name):
value = getattr(self, 'clean_%s' % name)()
self.cleaned_data[name] = value
except ValidationError as e:
self.add_error(name, e)

# django/forms/fields.py
# 필드 객체가 가진 clean함수이다. form 객체의 clean 함수과 혼동하지말자.
def clean(self, value):
value = self.to_python(value)
self.validate(value)
self.run_validators(value)
return value

self.fields로부터 폼의 필드 이름과 필드 객체를 반복하면서 작업을 수행한다. 폼 필드를 순회하면서 clean함수를 호출하며, 이 clean 함수는 각각의 필드가 가진 validator를 이용하여 값을 검증하고 반환한다. 필드의 clean 함수로부터 반환된 값은 필드 이름을 key로 하는 cleaneddata 딕셔너리에 저장된다. 이 때, 폼 클래스의 객체가 **clean필드명\\ 함수를 가지고 있으면 이 함수를 실행한 결과를 cleaned_data에 다시 한 번 저장한다.

만약 폼 클래스에 title 필드와 함수가 아래와 같이 있다면,

1
2
3
4
5
6
7
class PostForm(forms.Form):
title = forms.CharField()

def clean_title(self):
title = self.cleaned_data.get('title')
# title 값 처리
return title

title 필드의 clean() -> clean_title() 의 순서로 cleaned_data를 만드는 것이다.

물론 호출 과정에서 에러가 발생하게 된다면 개별 필드의 에러로 등록되어 is_valid 유효성 검증에 실패하게 된다.

_clean_field 함수 호출과정이 마무리되면 이어서 _clean_form가 실행된다.

full_clean 함수의 _clean_form 함수

1
2
3
4
5
6
7
8
9
10
11
12
# django/forms/forms.py
def _clean_form(self):
try:
cleaned_data = self.clean()
except ValidationError as e:
self.add_error(None, e)
else:
if cleaned_data is not None:
self.cleaned_data = cleaned_data

def clean(self):
return self.cleaned_data

폼 객체의 clean 함수를 실행하여 받아온 인스턴스 변수 cleaned_data를 _clean_form 함수의 cleaned_data 변수로 만든다. 에러가 있다면 non_field_error가 생기며, 에러가 없다면 인스턴스 변수 self.cleaned_data에 clean()된 값이 들어간다.

만약 아래와 같은 코드가 있다면

1
2
3
4
5
6
7
8
class PostForm(forms.Form):
title = forms.CharField()

def clean_title(self):
pass

def clean(self):
pass

title 필드의 clean() -> clean_title() -> 폼 객체의 clean()의 순서로 함수가 실행된다.

마지막으로 _post_clean 함수가 남아있다.

full_clean 함수의 _post_clean 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# django/forms/forms.py
def _post_clean(self):
"""
An internal hook for performing additional cleaning after form cleaning
is complete. Used for model validation in model forms.
"""
pass

# django/forms/models.py
def _post_clean(self):
opts = self._meta

exclude = self._get_validation_exclusions()

for name, field in self.fields.items():
if isinstance(field, InlineForeignKeyField):
exclude.append(name)

try:
self.instance = construct_instance(self, self.instance, opts.fields, opts.exclude)
except ValidationError as e:
self._update_errors(e)

try:
self.instance.full_clean(exclude=exclude, validate_unique=False)
except ValidationError as e:
self._update_errors(e)

if self._validate_unique:
self.validate_unique()

보다시피 일반 폼에서는 _post_clean 함수의 역할은 없다. 주석이 의미하는 바 처럼 모델 폼에서 모델 유효성 검사를 위해 사용되는 함수라고 생각하면 된다.

full_clean 함수의 모든 과정을 에러없이 마무리했다면 self._errors에는 값이 없기 때문에 부울 값이 False가 되고

1
2
def is_valid(self):
return self.is_bound and not self.errors

결국 is_valid 함수는 True를 반환해서 form.is_valid를 통한 유효성 검증이 끝나게 되는 것이다.

메타클래스 등 아직 더 살펴보아야 할 것이 있지만 이해한 만큼만 정리를 해 보았다. 추후에는 조금 더 나아가서 ModelForm까지도 정리해보자!!


[참고]
https://github.com/django/django/tree/2.2.3

Share