[9/20] 배열

글을 시작하기에 앞서 해당 시리즈는 Allen Downey, Ben Lauwens의 저서인 Think Julia: How to Think Like a Computer Scientist 를 바탕으로 작성된 글임을 알려드립니다.

이 포스트는 Arrays를 한글로 요약 정리한 글입니다.

배열 (Arrays)

이번 장에서는 줄리아에서 가장 유용한 데이터 타입인 배열(Arrays)을 살펴볼 것이다. 또한 한 객체에 둘 이상의 이름이 있을 때 발생할 수 있는 작업에 대해서도 알아볼 것이다.

배열은 시퀀스이다

문자열처럼 배열은 일련의 값들이다. 문자열에서는 값들이 모두 문자이지만, 배열에서는 어떤 데이터 타입이든 값이 될 수 있다. 배열에서 값은 요소(element or item)라고 한다.

배열을 만드는 몇 가지의 방법이 있다. 가장 간단한 방법은 대괄호[]로 요소들을 묶는 것이다.

1
2
[10, 20, 30, 40]
["crunchy frog", "ram bladder", "lark vomit"]

위의 첫 번째 예시는 4개의 정수들을 요소로 한 배열이며, 두 번째는 3개의 문자열들을 요소로 한 배열이이다. 배열의 요소들이 모두 동일한 타입일 필요는 없다. 아래의 배열은 문자열과 소수, 정수, 그리고 또 다른 배열이 요소로 구성되어 있다.

1
["spam", 2.0, 5, [10, 20]]

위의 예시처럼 배열은 다른 배열을 중첩하여 포함할 수 있다.

요소들을 포함하지 않은 배열은 빈 배열(empty array)이라고 부른다. 빈 배열은 대괄호[]만 입력해서 만들 수 있다.

예상했겠지만, 배열들은 변수의 값으로 사용할 수 있다.

1
2
3
4
5
6
7
8
julia> cheeses = ["Cheddar", "Edam", "Gouda"];

julia> numbers = [42, 123];

julia> empty = [];

julia> print(cheeses, " ", numbers, " ", empty)
["Cheddar", "Edam", "Gouda"] [42, 123] Any[]

typeof()는 배열의 종류를 찾는 데 사용된다.

1
2
3
4
5
6
julia> typeof(cheeses)
Array{String,1}
julia> typeof(numbers)
Array{Int64,1}
julia> typeof(empty)
Array{Any,1}

배열의 종류는 중괄호로 표시되어 출력되며, 앞에는 요소들의 데이터 타입을 보여준다. 뒤에 나오는 숫자의 의미는 배열의 차원이다. 또한 빈 배열은 Any라는 데이터 타입을 가지고 있다. 즉, 모든 값들은 데이터 타입을 가질 수 있는 것이다.

배열은 변할 수 있다

배열의 요소에 접근하는 문법은 문자열에서 문자에 접근하는 문법과 동일하게 []를 사용한다. 표현식은 []에 특정 인덱스를 넣어서 호출한다. 인덱스는 1부터 시작한다는 것을 기억해라

1
2
julia> cheeses[1]
"Cheddar"

문자열과 같이 배열들 또한 변할 수 있다. 대괄호 연산자가 할당문 왼쪽에 쓰인다면, 대괄호가 지정한 인덱스에 해당하는 배열 요소의 값이 할당된다.

1
2
3
4
5
6
julia> print(numbers)
[42,123]
julia> numbers[2] = 5
5
julia> print(numbers)
[42, 5]

위의 코드에서는 배열의 두 번째 값이 5로 재할당되었다.

배열 인덱스들은 문자열 인덱스들과 같은 방식으로 작동한다.

  • 어떤 정수 표현식이나 인덱스로 사용할 수 있다.
  • 만약 존재하지 않는 요소를 읽거나 사용하려고 한다면, BoundsError가 발생한다.
  • 키워드 end는 배열의 마지막 인덱스를 가져온다.

연산자 또한 배열에서 작동한다.

1
2
3
4
julia> "Edam" ∈ cheeses
true
julia> "Brie" in cheeses
false

배열 순회 (Traversing an Array)

배열 요소들을 순회하는 가장 보편적인 방법은 for루프를 사용하는 것이다. 문법은 문자열과 동일하다.

1
2
3
for cheese in cheeses
println(cheese)
end

오로지 배열 요소들을 읽기만 하는 것은 위의 코드로 충분하다. 그러나 만약 요소를 다시 쓰거나 업데이트하기를 원한다면, 인덱스가 필요하다. 이를 해결하는 가장 일반적인 방법은 내장 함수인 eachindex()를 사용하는 것이다.

1
2
3
for i in eachindex(numbers)
numbers[i] = numbers[i] * 2 # 기존 요소들에 2를 곱해주는 표현식
end

위의 루프는 배열을 순회하여 요소들을 업데이트한다. length()는 배열에서 요소들의 길이를 반환한다. 루프가 한 번씩 작동할 때마다 i는 다음 요소의 인덱스로 갱신된다. 본문에서의 할당문은 i를 사용하여 요소의 이전 값을 가져온 후 새로운 값으로 재할당한다.

아래의 for루프는 절대 본문이 실행되지 않는다.

1
2
3
for x in []
println("This can never happens.")
end

한 배열이 다른 배열을 포함하고 있을 때, 내부의 중첩 배열은 하나의 요소로 인식된다. 따라서 아래의 배열은 길이가 4이다.

1
["spam", 1, ["Brie", "Roquefort", "Camembert"], [1, 2, 3]]

배열 슬라이스 (Array Slices)

슬라이스 연산자 또한 배열에서 작동한다.

1
2
3
4
5
julia> t = ['a', 'b', 'c', 'd', 'e', 'f'];
julia> print(t[1:3])
['a', 'b', 'c']
julia> print(t[3:end])
['c', 'd', 'e', 'f']

슬라이스 연산자인 [:]는 전체 배열의 복사본을 가져온다.

1
2
julia> print(t[:])
['a', 'b', 'c', 'd', 'e', 'f']

배열은 가변적이기 때문에, 배열을 변경하기 전에 복사본을 만드는 방법은 종종 유용하게 사용된다.

변수 왼쪽에 사용된 슬라이스 연산자는 여러 개의 요소를 업데이트할 수 있다.

1
2
3
4
julia> t[2:3] = ['x', 'y'];

julia> print(t)
['a', 'x', 'y', 'd', 'e', 'f']

배열 라이브러리

줄리아는 배열에서 작동하는 함수들을 제공한다. 예를 들어, push!()는 배열 끝에 새로운 요소를 추가한다.

1
2
3
4
5
6
julia> t = ['a', 'b', 'c'];

julia> push!(t, 'd');

julia> print(t)
['a', 'b', 'c', 'd']

append!()는 첫 번째 인수 배열 끝에 두 번째 인수인 배열을 추가한다.

1
2
3
4
5
6
7
8
julia> t1 = ['a', 'b', 'c'];

julia> t2 = ['d', 'e'];

julia> append!(t1, t2);

julia> print(t1)
['a', 'b', 'c', 'd', 'e']

위의 예시에서 t2는 수정되지 않은 채로 유지된다.

sort!()는 낮은 단위에서 높은 단위 순으로 배열 요소들을 정리한다.

1
2
3
4
5
6
julia> t = ['d', 'c', 'e', 'b', 'a'];

julia> sort!(t);

julia> print(t)
['a', 'b', 'c', 'd', 'e']

sort()는 배열 요소들을 순서에 맞게 정리한 복사본을 반환한다.

1
2
3
4
5
6
7
8
julia> t1 = ['d', 'c', 'e', 'b', 'a'];

julia> t2 = sort(t1);

julia> print(t1)
['d', 'c', 'e', 'b', 'a']
julia> print(t2)
['a', 'b', 'c', 'd', 'e']

sort()sort!()의 차이점은 배열이 영구적으로 변하는지에 있다. 쉽게 이야기하면, sort!()를 사용하면 배열의 순서가 영구적으로 변하지만, sort()를 사용하면 기존 배열은 변하지 않고 순서가 변경된 배열만 보여준다.

Note 줄리아에서 사용되는 !는 인수를 변경하기 위한 함수 뒤에 추가한다.

맵, 필터 그리고 리듀스 (Map, Filter and Reduce)

배열에 모든 숫자들을 더하기 위해서는 아래의 루프를 사용하면 된다.

1
2
3
4
5
6
7
function addall(t)
total = 0
for x in t
total += x
end
total
end

total은 0으로 시작한다. 각 루프가 실행될 때마다 +=는 배열로부터 한 개의 요소를 얻는다. += 연산자는 변수를 업데이트하는 간단한 방법을 제공한다. 아래의 코드는 '증강 할당문(augmented assignment statement)'이며,

1
total += x

위의 코드는 아래의 코드와 같은 의미이다.

1
total = total + x

루프가 실행될 때, total은 요소들의 합을 누적한다. 이런 방식으로 사용되는 변수를 '누적 계산기(accumulator)'라고 부른다.

배열 요소를 합산하는 것은 줄리아가 제공하는 내장 함수인 sum()을 사용하면 된다.

1
2
3
4
julia> t = [1, 2, 3, 4];

julia> sum(t)
10

시퀀스로 연결되어 있는 요소들을 하나의 요소로 결합하는 작업을 리듀스 작업(reduce operation)이라고 한다.

다른 배열을 만들기 위해서 한 배열을 순회하는 경우도 있다. 예를 들어, 아래의 함수는 기존 문자열들의 대문자를 반환하여 새로운 배열을 만든다.

1
2
3
4
5
6
7
function capitalizeall(t)
res = []
for s in t
push!(res, uppercase(s))
end
res
end

res는 빈 배열을 만든다. 루프가 매번 실행될 때마다, res에 요소들을 추가한다. 따라서 res도 누적 계산기로 봐도 상관없다.

capitalizeall()와 같은 작동방식은 가끔 맵(map)이라고 불린다. 왜냐하면 uppercase()처럼 순서대로 각 요소를 찾아 매핑(mapping)하는 함수가 있기 때문이다.

또다른 일반적인 작동방식은 배열에서 일부 요소를 선택하여 '하위 배열(subarray)'을 반환하는 것이다. 예를 들어 아래의 함수는 문자열 배열을 가져와서 대문자만 포함하는 하위 배열을 반환한다.

1
2
3
4
5
6
7
8
9
function onlyupper(t)
res = []
for s in t
if s == uppercase(s)
push!(res, s)
end
end
res
end

위의 예시처럼 조건에 적합한 요소만을 선택하여 가져오는 onlyupper()과 같은 작동방식을 필터(filter)라고 한다.

가장 일반적인 배열 작동방식은 맵, 필터, 리듀스의 결합으로서 표현된다.

도트 문법(Dot Syntax)

^와 같은 모든 이항 연산자(binary operator)에는 대응하는 도트 연산자가 있다. 아래의 코드는 요소마다 ^연산자가 자동으로 실행되도록 정의하였다. [1, 2, 3] .^ 3는 자체적으로 정의되지 않았지만, 컴퓨터는 뒤의 .^ 3가 앞의[1, 2, 3]에 각각 적용되어 [1^3, 2^3, 3^3]의 결과를 도출한다.

1
2
julia> print([1, 2, 3] .^ 3)
[1, 8, 27]

모든 줄리아 함수는 어떤 배열에서든 도트 문법을 사용하여 요소별로 적용할 수 있다 예를 들어, 문자열 배열을 루프를 사용하지 않고도 대문자로 표시할 수 있다.

1
2
3
4
julia> t = uppercase.(["abc", "def", "ghi"]);

julia> print(t)
["ABC", "DEF", "GHI"]

이것은 더 깔끔하게 맵을 만드는 방법이다. capitalizeall()를 한 줄로 구현할 수 있다.

1
2
3
function capitalizeall(t)
uppercase.(t)
end

요소 삭제와 삽입

배열에서 요소를 삭제하는 방법은 여러 개가 있다. 만약 삭제하고 싶은 요소의 인덱스를 알고 있다면, splice!()를 사용하면 된다.

1
2
3
4
5
6
julia> t = ['a', 'b', 'c'];

julia> splice!(t, 2)
'b': ASCII/Unicode U+0062 (category Ll: Letter, lowercase)
julia> print(t)
['a', 'c']

splice!()는 배열을 수정하고 해당 요소가 제거된 배열을 반환한다.

pop!()은 마지막 요소를 제거한 후 반환한다.

1
2
3
4
5
6
julia> t = ['a', 'b', 'c'];

julia> pop!(t)
'c': ASCII/Unicode U+0063 (category Ll: Letter, lowercase)
julia> print(t)
['a', 'b']

popfirst!()은 첫 번째 요소를 제거한 후 반환한다.

1
2
3
4
5
6
julia> t = ['a', 'b', 'c'];

julia> popfirst!(t)
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)
julia> print(t)
['b', 'c']

pushfirst!()push!()는 배열의 시작과 끝에 각각 요소를 삽입한다.

만약 제거된 요소가 필요없다면, deleteat!()을 사용할 수 있다.

1
2
3
4
julia> t = ['a', 'b', 'c'];

julia> print(deleteat!(t, 2))
['a', 'c']

insert!()는 주어진 인덱스에 요소를 삽입한다.

1
2
3
4
julia> t = ['a', 'b', 'c'];

julia> print(insert!(t, 2, 'x'))
['a', 'x', 'b', 'c']

배열과 문자열

문자열을 문자의 나열이고 배열은 값들의 나열이지만, 문자 배열과 문자열은 다르다. collect()를 사용하면 문자열에서 문자 배열로 변환할 수 있다.

1
2
3
4
julia> t = collect("spam");

julia> print(t)
['s', 'p', 'a', 'm']

collect()는 문자열을 깨고 문자들을 각각의 요소로 변경해준다.

만약 문장을 단어 단위로 쪼개고 싶다면, split()를 사용하면 된다.

1
2
3
4
julia> t = split("pining for the fjords");

julia> print(t)
SubString{String}["pining", "for", "the", "fjords"]

split()에서 구분 기호 등을 추가적 인수로 사용하면 구분 기호에 따라서 단어로 나눠준다. 아래의 예시는 하이픈(-)기호를 추가 인수로 사용하였다.

1
2
3
4
julia> t = split("spam-spam-spam", '-');

julia> print(t)
SubString{String}["spam", "spam", "spam"]

join()split()의 반대이다. 단어들 사이에 구분자를 넣고 싶다면 join()을 사용하면 된다.

1
2
3
4
julia> t = ["pining", "for", "the", "fjords"];

julia> s = join(t, ' ')
"pining for the fjords"

위의 예시에서는 구분 기호로 공백을 사용하였다. 만약 공백 없이 단어를 연결하고 싶다면 구분 기호를 인수로 추가하지 않으면 된다.

객체와 값

객체(object)는 변수가 참고할 수 있는 어떤 것이다. 지금까지는 객체와 값을 동일시하여 사용할 수 있었다.

아래의 할당문을 실행해보자.

1
2
a = "banana"
b = "banana"

위의 예시는 ab가 동일한 문자열을 가지고 있다는 것을 보여준다. 하지만 컴퓨터도 동일한 문자열로 인식하는지, 아니면 각각 다른 문자열로 인식하는지는 알 수 없다.

state diagrams

첫 번째의 경우는 동일한 값의 다른 객체를 ab가 가지고 있는 경우이며, 두 번째는 ab가 동일한 객체를 가지고 있는 경우이다.

두 변수가 동일한 객체를 가지고 있는지 확인하려면, === 또는 (\equiv TAB)연산자를 사용하면 된다.

1
2
3
4
5
6
julia> a = "banana"
"banana"
julia> b = "banana"
"banana"
julia> a ≡ b
true

해당 예시에서는 줄리아가 오로지 하나의 문자열 객체를 만들었다. 하지만 배열의 경우에는 각각의 배열 객체들이 생성된다.

1
2
3
4
5
6
7
8
9
julia> a = [1, 2, 3];

julia> b = [1, 2, 3];

julia> a == b
true

julia> a ≡ b
false

== 연산자는 불 연산자로 두 변수의 값이 같은지의 여부를 ture 또는 false로 반환한다. 위의 예시를 보면 ab의 값은 같기 때문에 == 연산자의 결과는 ture를 반환하지만, 객체가 동일한지 판단하는 === 연산자의 결과는 false를 반환한다.

state diagrams

위의 예시에서 두 배열은 동일한 요소들을 가지고 있기 때문에 두 객체가 같다(same)고 설명할 수 있지만, 두 객체가 동일(identical)하다고 말할 수는 없다. 즉, 두 객체가 동일하다면 같다고 말할 수 있지만, 두 객체가 같다고 해서 동일하다고 할 수는 없다.

정확하게 설명하면 객체는 값을 가지고 있다. 예로 [1,2,3]는 값이 정수 시퀀스인 배열 객체를 얻는다. 만약 다른 배열에 동일한 요소가 있다면, 값은 같지만 동일한 객체라고 가정하지는 않는다.

에일리어싱 (Aliasing)

객체를 가진ab=a를 통해 b를 할당한다면, 두 변수는 같은 객체를 가진다.

1
2
3
4
5
6
julia> a = [1, 2, 3];

julia> b = a;

julia> b ≡ a
true
state diagrams

변수와 객체와의 연관성을 레퍼런스(reference)라고 한다. 이 예에서는 동일한 객체에 대한 두 개의 레퍼런스가 있다.

한 개 이상의 레퍼런스를 가진 객체는 둘 이상의 이름을 가지게 되므로 그 객체는 에일리어싱되었다고 설명한다.

에일리어싱된 객체의 값이 변하는 경우, 그 변화는 연결된 변수들에게도 적용된다.

1
2
3
4
5
julia> b = [1, 2, 3]
julia> b[1] = 42
42
julia> print(a)
[42, 2, 3]

WARNING 위의 방식은 유용하게 사용되지만, 동시에 오류가 발생하기 쉽다. 일반적으로 변경 가능한 객체로 작업할 때에는 에일리어싱을 피하는 것이 더 안전하다.

문자열과 같이 변경 불가능한 객체의 경우에는 에일리어싱이 큰 문제가 되지 않는다. 아래의 예시를 보자.

1
2
a = "banana"
b = "banana"

위의 예사에서는 ab가 동일한 객체를 가지는지 아닌지에 대한 여부는 큰 차이가 없다는 것을 볼 수 있다.

배열 인수

배열을 함수에 전달하면, 함수는 배열을 참조한다. 그래서 함수가 배열을 수정하면 사용자에게 변경된 내용이 표시된다. 예를 들어 deletehead!()를 사용하여 첫 번째 요소를 삭제해보자.

1
2
3
function deletehead!(t)
popfirst!(t)
end

실행 흐름을 확인해보면 인수로 입력된 배열을 가져와서 popfirst!()로 첫 번째 요소를 삭제한다. !가 입력된 함수는 기존의 배열을 수정한다.

1
2
3
4
5
6
julia> letters = ['a', 'b', 'c'];

julia> deletehead!(letters);

julia> print(letters)
['b', 'c']

매개변수 t와 변수 letters는 객체의 에일리어스이다. 아래의 그림에서 확인해보자.

state diagrams

배열을 수정하는 작업과 새 배열을 만드는 작업을 구분하는 것은 매우 중요하다. 예를들어 push!()는 배열을 수정하지만 vcat()은 새로운 배열을 만든다.

1
2
3
4
5
6
julia> t1 = [1, 2];

julia> t2 = push!(t1, 3);

julia> print(t1)
[1, 2, 3]

t2t1의 에일리어스이다.

아래의 코드는 vcat()의 예시이다.

1
2
3
4
5
6
jjulia> t3 = vcat(t1, [4]);

julia> print(t1)
[1, 2, 3]
julia> print(t3)
[1, 2, 3, 4]

vcat()t3라는 새로운 배열을 만들었으며, t1은 변하지 않았다.

이런 차이점은 배열을 수정해야 하는 함수를 작성할 때 매우 중요하다.

예를 들어, 아래의 함수는 배열의 앞부분(head)을 삭제하지 않는다.

1
2
3
function baddeletehead(t)
t = t[2:end] # WRONG!
end

슬라이스 연산자는 새 배열을 만들고 할당 연산자는 해당 배열을 참조하여 새 배열의 값을 정한다. 하지만 이것들은 호출된 배열에 영향을 주지는 않는다.

1
2
3
4
5
6
julia> t4 = baddeletehead(t3);

julia> print(t3)
[1, 2, 3, 4]
julia> print(t4)
[2, 3, 4]

baddeletehead()의 시작부분에서 t4t3는 동일한 배열을 나타낸다. 마지막에는 t4는 새 배열을 나타내지만 t3는 이전 배열을 그대로 유지하고 있다.

대안은 새로운 배열을 생성하고 반환하는 함수를 작성하는 것이다. 예를 들어 tail()은 배열의 첫 번째 요소를 제외한 모든 요소를 반환한다.

1
2
3
function tail(t)
t[2:end]
end

위의 함수는 기존의 배열을 수정하지 않은 채로 유지한다.

1
2
3
4
5
6
julia> letters = ['a', 'b', 'c'];

julia> rest = tail(letters);

julia> print(rest)
['b', 'c']

디버깅

배열 및 기타 변경 가능한 객체 등을 주의해서 사용하지 않으면 오랜 시간 디버깅 할 수 있다. 일반적인 함정을 피하는 방법은 아래와 같다.

  • 대부분의 배열 함수들은 인수를 수정한다. 이는 문자열 함수가 기존 문자열을 그대로 두고 새로운 문자열을 반환하는 것과 정확히 반대이다.

문자열 코드를 작성하는 데 익숙한 경우에는

1
new_word = strip(word)

다음과 같이 배열 코드도 작성하려고 한다.

1
t2 = sort!(t1)

sort!()은 수정된 원래 t1을 반환하고 t2t1의 에일리언스이다. 즉, sort!()를 사용할 때는 원본이 수정되기 때문에 새로 할당할 필요가 없다는 것이다.

Tip 배열 함수와 연산자를 사용하기 전에 설명서를 주의해서 읽고 대화식 모드에서 테스트를 해보는 것이 좋다.

  • 관용구를 선택해서 붙여라 배열과 관련된 문제 중 하나는 작업을 수행하는 방법이 너무 많다는 것이다. 예를 들어 배열에서 요소를 제거하려면 pop!, popfirst!, delete_at 또는 슬라이스 할당을 사용할 수도 있다. 요소를 추가하는 것은 push!, pushfirst!, insert! 또는 vcat을 사용한다. t가 배열이고 x가 배열 요소라고 가정하여 배열에 추가한다면 아래의 코드와 같다.

1
2
3
insert!(t, 4, x)
push!(t, x)
append!(t, [x])

그리고 아래는 올바르지 못한 코드이다.

1
2
3
insert!(t, 4, [x])         # WRONG!
push!(t, [x]) # WRONG!
vcat(t, [x]) # WRONG!

  • 에일리언싱을 피하기 위해 복사본을 만들어라 sort!와 같은 함수를 사용하려면 인수를 수정하지만 원래 배열도 가지고 있어야 한다. 복사본을 만들어서 가지고 있는 것이 좋다.

1
2
3
4
5
6
7
8
9
10
julia> t = [3, 1, 2];

julia> t2 = t[:]; # t2 = copy(t)

julia> sort!(t2);

julia> print(t)
[3, 1, 2]
julia> print(t2)
[1, 2, 3]

위의 예시에서는 내장 함수인 기존의 배열은 유지하며 새로운 배열로 반환하는 sort를 사용하는 것이 좋다.

1
2
3
4
5
6
julia> t2 = sort(t);

julia> println(t)
[3, 1, 2]
julia> println(t2)
[1, 2, 3]


[9/20] 배열
https://dev-bearabbit.github.io/ko/ThinkJulia/Think-Julia-Chapter-9/
Author
Jess
Posted on
2020년 3월 8일
Licensed under