본문 바로가기
삽질/Python

[ Python 삽질 ] List의 원소가 한번에 다 바뀔때 deep copy? shallow copy?

by SteadyForDeep 2021. 6. 2.
반응형

 

 

사실 이번 글은 정말 부끄러운 내용이다...

초보적인 실수이면서도 정말 잘 고쳐지지 않는 내용이므로 확실하게 짚고 넘어가고자 한다.

 

// 문제 상황

 

코딩 테스트를 보는데 이미지나 표가 나왔다.

리스트의 리스트를 만들어서 행렬처럼 쓴다고 가정하자.

먼저 zeros를 만들면

이렇게 해서

이렇게 나올 것이고

그다음 이렇게 가운데에 행렬을 복사해 넣어서

 

zero padding 효과를 주고 싶다고 하면 잘 될까?

결과는 이렇게 나온다.

 

특히나 numpy의 array를 많이 접해본 경험이 있거나

 

list 안에 단순한 숫자가 아닌 여러 다른 특성의 객체를 담게 되는 경우

 

이런 실수는 정말 무의식 중에 비일비재하게 일어난다. 

 

 

 

// 해결 방안

 

사실 현장에서는 꼭, 굳이 list of list를 만들어서 사용해야 되는 상황이 잘 없기 때문에

 

이건 그냥 코딩 테스트를 위한 일종의 팁 같은 것이라고 할 수 있다.

 

위와 같은 형식으로 zeros를 생성하면된다.

 

 

 

 

// 원인

 

1.

이 문제는 파이썬의 고질적인 문제인 copy의 깊이 때문에 발생한다.

 

이 깊이를 만드는 주요한 원인은 바로 Call-by-object-reference이다.

 

간혹 C를 이용해서 짜둔 코드를 C++로 바꾸거나 이식하는 과정에서

 

함수 내부의 변숫값을 바꾸고 함수를 return 했는데

 

이게 결과적으로는 반영되지 않아서 버그가 잡히는 경우가 있다.

 

물론 이 문제는 파이썬에서도 어마 무시하게 일어난다.

 

그것뿐만이 아니라 (특히 파이썬에서는) 여러 변수들이 독립적으로 움직이지 않고

 

한 번에 몽땅 바뀌어버리는 현상들이 종종 나타나는데

 

이 현상을 정확하게 다루기 위해서는 call-by-어쩌구 를 꼭 알고 있어야 한다.

 

 

2.

간단하게 요약하자면

 

우리는 "메모리"라고 하는 가상의 공간을 가지고 있는데

 

이 공간에 어떤 "값(value)" 을 가진 정보를 저장하면 (혹은 선언하면)

 

반드시 이 값이 "이 공간의 어디에 위치(reference)"하고 있는지에 관한 정보도 함께 발생한다.

 

그리고 이 정보를 다루기 위해서 변수로 설정을 할 때

 

값으로 부를 것이냐 위치로 부를 것이냐 하는 문제가 바로 이 call-by-어쩌구 하는 문제다.

 

 

 

 

3.

말로 하면 백날 백 마디를 해도 이해할 수도, 이해시킬 수도 없을 것 같다.

 

백문이 불여일타. 한번 코드로 보자.

파이썬을 쓰는 사람들은 꼭 한번 마주하게 되는 환장하는 예시인데

 

이렇게 B만 바꿔도 A가 바뀌는 이유는 B를 선언할 때 A의 값이 아닌 주소를 선언했기 때문이다.

 

그러니까 저 [0, 1, 2, 3] 이라는 리스트는 사실 A고 B고 상관없이

 

독자적으로 존재하는, 다만 값과 주소만 가지는 이름 없는 정보이고

 

이 정보에 이름을 붙이는 연산이 "=" 인 것이다. 다른 말로 앞으로는 이렇게 부르자(call)고 하는 과정이다.

 

그러면 A에는 이 리스트의 값이 담겨 있을까?

 

그렇지 않다.

 

파이썬은 기본적으로 모든 자료를 주소(reference)로 호출하게 되어있다.

 

즉 우리가 A를 코드 상에서 사용하면 인터프리터가(파이썬이) A에 담겨있는 주소로 찾아가고

 

그 위치에 무슨 값이 있는지를 본 후에 처리를 진행한다.

 

심하게 말하면 A와 A의 값은 아무 상관이 없다.

 

파이썬에는 id 라는 내장 함수가 있다.

 

이 함수는 우리가 선언한 변수가 가리키는 주소값을 반환한다.

 

A의 주소와 B의 주소가 같은 것을 알 수 있다.

 

그렇기 때문에 B에 수정을 가하면 같은 주소를 공유하는 A에도 똑같은 변화가 생기는 것이다.

 

 

 

4.

그러면 A와 같으면서 A와 독립된 B를 만드는 방법은 무엇일까?

 

사실 A와 완벽하게 독립적인 B를 만드는 것은 거의 불가능하다.

 

하지만 새로운 주소를 할당하고 거기에 A의 값만을 똑같이 집어넣으면 기능적으로는 가능하다.

 

[:] 이라는 세 개의 기호만 넣어주면

이렇게 A의 리스트 주소와 B의 리스트 주소는 달라지게 된다.

 

이렇게 독립적인 객체를 만들면서 값을 복사하는 과정을 deep copy 라고 하고

 

그냥 주소만 공유하는 복사를 shallow copy 라고 한다.

 

파이썬의 기본 복사는 모두 shallow copy다.

 

5.

여기까지 이해하면 아~ 할 수 있다.

 

근데 여기서 한번 더 꼬이면서 머리가 아픈데;

 

이 부분이 이번 글의 핵심이다.

왜 깊이로 이야기하는지 알겠는가?

 

객체가 객체를 담고 그 객체가 다시 객체를 담는

 

겹층 구조에서는

 

어느 층 까지를 복사 할지

 

어느 층 부터는 주소만 공유할지

 

이걸 선택해야 한다...

 

그러니까 copy의 깊이를 논하는 것이다.

 

여기서도 보면 우리가 새로 할당해서 만든 자료는 "리스트"이다.

 

그리고 그 리스트의 내용물은 여전히 shallow copy로 주소만을 공유한다.

 

따라서 리스트를 한 겹만 더 주게 되면

이렇게 A의 내부 값도 바뀌는 대참사가 발생한다ㅠ

 

이렇게 하나하나 디버깅할 수 없는 코딩 테스트의 경우라면

 

정말 많은 시간을 낭비하게 되는 요인이다.

 

이렇게 된 원인은 아까도 말한 copy의 깊이가 우리가 원하는 수준보다 얕았기 때문인데

 

list[ list[ int ] ] 이런 구조는 가장 겉 껍데기 리스트만이 deep copy가 되고

 

나머지 안쪽 list와 int는 shallow copy에 그친다.

 

 

 

6.

아니 그러면 이렇게 하면 되는 거 아님?

 

이라고 생각할 수 있겠지만.. 해보면 결과는 동일하다...ㅠ 왜냐하면

A 와 A[:] 가 겉 껍데기만 다르고 속은 같은 list of list 이기 때문이다.

 

그래서 ( A[:] )[:] 를 해도 속은 여전히 shallow copy만 계속 되는 대환장파티가 펼쳐진다.

 

바로 직전에 다룬건데 이렇게 보면 또 새롭다.

 

그만큼 처음에는 완벽하게 "copy의 깊이" 개념을 잡기가 힘들다.

 

7.

list[ list[ int ] ] 의 구조에서 일반적인 직관에 해당하는 deep copy를 해내려면

 

가장 알맹이인 int 빼고 모든 list의 주소를 새로 할당해 주어야한다.

 

파이썬은 맨처음 인터프리터를 시작할때 int는 자동으로 생성하고

 

이 int들의 주소만 받아오는 형식으로 작업을 하기때문에

 

이 int 까지 새로운 주소로 복사할 필요는 없다.

 

우리가 구분해야 하는 것은 리스트다.

 

def list_copy(list_of_list, i_range = None, j_range = None):
    """ for the deep copy with an arbitrary range
    Args :
        list_of_list : An list of list, shape of N X M.
                        The original list of a copy.
        i_range : An tuple or list of two integers.
                        The column index range of original list.
        i_range : An tuple or list of two integers.
                        The row index range of original list.
    """
    if not i_range:
        i_range = (0, len(list_of_list))
    if not j_range:
        j_range = (0, len(list_of_list[0]))

    result = []
    for i in range(*i_range):
        row = []
        for j in range(*j_range):
            row.append(list_of_list[i][j])
        result.append(row)
    return result

 

따라서 이런 함수를 하나 만들어서 사용하는 것이 정신력을 아끼는데 도움을 준다.

 

8.

다시 원래 문제로 돌아와서..

 

리스트의 기본 연산중에 곱하기는 단지 원소끼리의 shallow copy를 지원할 뿐이다.

 

그래서 

이렇게 zeros를 만들면 모든 내부의 리스트들이 같은 주소를 공유하게 된다.

 

그래서 마지막에 수정한 값으로 모두 통일되어버리는 것이다.

 

하지만 list comprehension 을 이용한 표현으로

요렇게 선언해 주면 for문이 돌때마다 새로운 리스트를 만들어서

 

내부의 리스트들은 서로 다른 주소를 가지게 된다.

이거 때문에 카카오 기출 중에 "자물쇠와 열쇠" 푼다고 식겁했다.

 

다음부터는 꼭 기억하고 리스트를 다루자 ㅠ

반응형

댓글