F# Tutorial (4) - Collection
Tags: fsharp
Published: 2023-01-03 02:44:32

이번 글에선 F#에서 제공하는 collection에 대해 알아보겠습니다.
F#에서 지원하는 collection은 크게 List, Array, Sequence의 3종류가 있습니다.

#### List

List는 순서가 있고 immutable한 같은 type의 원소에 대한 collection입니다. 내부적으로 Singly linked list로 구현되어 있으며, 따라서 iteration은 효율적이지만 random access에는 그리 적합하지 않습니다.

F#에서 list는 `'T list`라는 type을 가지며 `[]`를 사용해서 정의합니다.
```fsharp
let list1 = []			// empty list
let list2 = [ 1; 2; 3; ]	// 1, 2, 3을 원소로 갖는 list
let list3 = [			// 여러 줄에 걸쳐 정의할 수도 있습니다.
    1
    2
    3
]
let list4 = [ 1 .. 1000 ]		// 1~1000까지의 정수를 원소로 갖는 리스트
```

List는 `::`(cons) 연산자를 통해 원소를 list에 연결할 수 있습니다.
List를 list에 연결하고 싶으면 `@`연산자를 대신 사용합니다.
```fsharp
let list1 = [ 1; 2; 3; ]
let list2 = 100 :: list1	// list2 = [ 100; 1; 2; 3; ]
let list3 = list1 @ list2	// list3 = [ 1; 2; 3; 100; 1; 2; 3; ]
```
주의할 점은, list는 immutable한 collection이기 때문에 list에 대한 수정은 반드시 새 list를 만든다는 점입니다. 절대 기존 list를 수정해주지 않습니다.

List는 Head와 Tail이라는 개념이 있습니다. Head는 list의 첫 번째 원소를 뜻하며, Tail은 그 나머지 전부를 모은 list를 뜻합니다. F#에서는 Head와 Tail이라는 property로 이를 지원합니다. 그 외에도 지원하는 property는 다음과 같습니다.

| Property | Type | 설명 |
| :-: | :-: | :- |
| Head | 'T | 첫 번째 원소 |
| Tail | 'T list | 첫 번째 원소를 제외한 나머지 원소들의 list |
| Empty | 'T list | 동일한 type의 원소를 갖는 빈 list |
| IsEmpty | bool | list가 비어있으면 true |
| Item | 'T | 특정 index의 원소 (zero-based) |
| Length | int | list의 원소의 개수 |

이미 한 번 언급했듯이, F#의 list에선 random access에 O(N)의 시간이 필요합니다. 따라서 List.Item(n)은 그다지 빠르지 않습니다.

#### 배열(Array)

배열은 고정된 크기의 mutable한 같은 type의 원소에 대한 collection입니다. 타 언어에서도 흔히 볼 수 있는 배열과 동일하며, 따라서 빠른 random access를 지원합니다.

F#에서 배열은 `'T array`라는 type을 가지며, `[| |]`를 사용해서 정의합니다.
```fsharp
let array1 = Array.empty	// empty array
let array2 = [| 1; 2; 3; |]	// 1, 2, 3을 원소로 갖는 배열
let array3 = [|			// 배열도 여러 줄에 걸쳐 정의할 수 있습니다.
    1
    2
    3
|]
let array4 : int array = Array.zeroCreate 10	// 0을 10개 갖는 array
```

배열은 `[]`를 통해 특정 원소에 접근할 수 있습니다. 늘 그렇듯 zero-base입니다.
```fsharp
let array1 = [| 1; 2; 3; 4; 5; |]
let a = array1[1]		// a = 2
let array2 = array1[1..3]	// array2 = [| 2; 3; 4; |]
let array3 = array1[..2]	// array3 = [| 1; 2; 3; |]
let array4 = array1[4..]	// array4 = [| 5; |]
```
여기에서도 `..`를 사용하여 범위를 지정할 수 있으며, 이를 slicing이라고 부릅니다. 주의할 점은 slicing을 사용할 경우, 실질적으로 해당되는 원소가 단 1개뿐이더라도 새 배열로 복사되어서 리턴한다는 점입니다. 이 경우에는 원본 배열을 변형시키지 않습니다.
단일 index를 사용하여 배열에 접근한 경우, `<-`로 해당 원소의 값을 수정할 수 있습니다.
배열의 접근/수정의 경우 `Array.get`, `Array.set` 함수로도 동일한 작업을 수행 할 수 있으며, `Array.fill` 함수를 사용해 여러 원소의 값을 일괄적으로 바꿀 수도 있습니다.
```fsharp
let array1 = [| 1..25 |]
Array.fill array1 2 20 0	// array1의 2~20번째 원소의 값을 0으로 변경
Array.get array1 2		// array1의 2번째 원소의 값 = 0
Array.set array1 1 10		// array1의 1번째 원소의 값을 10으로 변경
```

배열은 mutable하지만 fixed-size임에 주의해야 합니다. 이미 있는 원소의 값을 바꾸는건 자유롭지만 크기를 늘리는건 불가능합니다. `append`를 수행할 경우 다른 collection과 마찬가지로 원본의 변경 없이 새 배열을 만들어 리턴합니다.

다른 언어와 마찬가지로 F#에서는 다차원 배열을 지원합니다. 하지만 `[| |]`를 사용하는 것이 아니라 `array2D`, `array3D`, `array4D`를 사용해야만 합니다. 5차원 이상의 배열이 필요할 경우 직접 만들어야 합니다.
```fsharp
let array2d1 = array2D [ [ 1; 0; ]; [0; 1; ] ]
```

물론 `[| |]`를 사용하여 배열을 원소로 갖는 배열을 만들 수 있지만, index의 접근방법에서 다소 차이가 납니다.
```fsharp
let array1 = [| [| 1; 0; |]; [| 0; 1; |]; |]
let array2 = array2D [ [ 1; 0; ]; [ 0; 1; ] ]
array1[0][0]		// 1
array2[0, 0]		// 1
```

2차원 배열의 type은 `'T[,]`라고 정의되며, 3차원 배열은 `'T[,,]`로 정의됩니다. 그 이상의 차원에 대해서도 동일한 규칙이 적용됩니다.
각각의 차원에 대해 slicing을 적용할 수 있으며, 추가적으로 `*`을 통해 해당 차원의 모든 index에 대응시킬 수 있습니다. 그 외에는 1차원 배열과 동일한 규칙으로 적용됩니다.
```fsharp
matrix1[1.., *]
matrix1[1..3, *]
matrix1[*, 1..3]
matrix1[1..3, 1..3]
```

#### Sequence

Sequence는 동일한 type의 원소에 대한 논리적인 collection입니다. 다른 collection과는 다르게, sequence는 lazy evaluation을 지원합니다. 즉, 각각의 원소는 필요할 때 계산되며, 이 때문에 list나 array에 비해 요구하는 총 원소의 개수는 많지만 그들 전부가 필요하지는 않은 경우에 유용하게 사용할 수 있습니다.

F#에서 sequence는 `seq<T'>`라는 type을 가지며, `seq { }`를 사용해서 정의합니다.
```fsharp
let seq1 = Seq.empty				// empty sequence
let seq2 = seq { 1 .. 5 }			// 1 ~ 5를 원소로 갖는 sequence
let seq3 = seq { yield "Hello"; yield "World"; }// "Hello", "World"를 원소로 갖는 sequence
```
List나 배열과는 다르게 원소를 저장할 때 `yeild`를 사용합니다. 이에 대한 내용은 나중에 다루겠습니다. 지금은 `yield`를 사용해서 선언한다고만 알아두시면 됩니다.

Sequence의 경우 lazy evaluation이 기본적으로 지원되는 collection이기 때문에 '무한'을 다루는 데 적합합니다.
```fsharp
let seq1 = Seq.singleton 10	// 모든 원소가 10인 sequence
let seq2 = Seq.take 5 seq1	// seq { 10; 10; 10; 10; 10; }
```
위 코드에서 `seq1`은 길이가 딱히 정해져 있지 않습니다. 계산을 요청할 때마다 `10`을 리턴할 뿐이죠. `Seq.take` 혹은 `Seq.truncate` 함수를 사용하여 원소 몇 개를 가져오거나 `Seq.skip` 함수를 사용하여 몇 개를 건너뛸 수 있습니다.

#### 내장 함수들

각각의 collection에는 다양한 내장 함수들이 존재합니다. 각 collection에서 지원하는 모든 함수들의 목록은 [List](https://fsharp.github.io/fsharp-core-docs/reference/fsharp-collections-listmodule.html), [Array](https://fsharp.github.io/fsharp-core-docs/reference/fsharp-collections-arraymodule.html), [Sequence](https://fsharp.github.io/fsharp-core-docs/reference/fsharp-collections-seqmodule.html)를 참고하시면 됩니다. 여기서는 그 중 간단하면서도 자주 쓰이는 일부만 다루며, 나머지 중 일부는 이후의 글에서 다루겠습니다. Collection의 종류에 따라 지원하는 함수의 종류가 조금씩 다르지만, 아래에서 다루는 함수들은 list, 배열, sequence에서 전부 지원하며, 함수의 형태는 각각 `List.func`, `Array.func`, `Seq.func`의 형태입니다.

**1) exists / forall**
같이 제시된 함수의 조건을 만족하는 원소가 있는지를 체크하며 해당하는 원소가 하나라도 있다면 그 결과는 `true`가 됩니다.
```fsharp
let list1 = [ 0..3 ]
let result = List.exists (fun e -> e = 4) list1	// false
```
위의 예시에서는 `list1`에서 4를 가진 원소가 있는지를 체크하며, 그러한 원소가 없으니 결과는 `false`입니다.

`forall`의 경우 `exists`와는 다르게 모든 원소에 대해 주어진 조건을 통과하는지 체크합니다. 하나라도 실패한 원소가 있다면 그 결과는 `false`가 됩니다.

**2) sort / sortBy**
Collection의 원소를 정렬합니다.
```fsharp
let list1 = List.sort [1; -4; 8; 2; 5]				// [-4; 1; 2; 5; 8]
let list2 = List.sortBy (fun e -> abs e) [1; -4; 8; 2; 5]	// [1; 2; -4; 5; 8]
```
기본적인 `sort` 함수는 오름차순으로 정렬합니다. 만일 크기 비교가 불가능한 type이거나 별도의 기준이 필요할 경우, `sortBy` 함수를 통해 크기 비교의 기준이 될 함수를 같이 제공할 수 있습니다.

**3) find / tryFind**
Collection에서 주어진 기준에 만족하는 첫 번째 원소를 찾습니다. 만일 해당하는 원소가 하나도 없다면 System.Collections.Generic.KeyNotFoundException 예외를 발생시킵니다.
```fsharp
let result = List.find (fun e -> e % 5 = 0) [1..100]	// 5
```
위의 코드는 [1..100]에서 가장 먼저 등장하는 5의 배수를 찾으며, 그 결과는 5입니다.

`tryFind`함수를 사용하게 되면 예외를 발생시키지 않을 수 있으며, 검색에 실패할 경우 `None`을, 성공할 경우 `Some(x)`를 리턴하게 됩니다.

**4) sum / sumBy / average / averageBy**
Collection의 원소의 합(`sum`), 평균(`average`)를 계산합니다.
```fsharp
let sum1 = List.sum [1..10]	// 55
let avr1 = List.average [1..3]	// 2.0
```
`sort` 및 `sortBy`의 관계와 같이, 만일 별도의 계산 함수가 필요하다면 `sumBy`와 `averageBy`를 사용하면 됩니다.

Infinite sequence의 경우 에러가 나지는 않지만 의도하지 않은 결과가 나올 확률이 매우 높습니다. 무한으로 존재하는데 그걸 전부 더하라고 하면 무한루프가 일어나겠죠. 따라서 `take` 등을 사용해 적절히 잘라준 후 사용해야 합니다.

**5) zip / zip3 / unzip / unzip3**
F#에서 `zip`이란 2개의 collection을 각 원소가 기존 collection들을 묶은 tuple이 되는 하나의 collection으로 묶는 함수를, `unzip`은 반대로 각 원소가 tuple인 collection을 tuple의 개수만큼 분리하는 함수를 뜻합니다.
```fsharp
let list1 = [1; 2; 3]
let list2 = [-1; -2; -3]
let zipList = List.zip list1 list2	// [(1, -1); (2, -2); (3, -3)]	
let listTuple = List.unzip zipList	// ([1; 2; 3], [-1; -2; -3])
```
만일 3개의 collection에 대해 이 작업을 수행하고 싶다면 `zip3`, `unzip3` 함수를 사용하면 됩니다.

**6) append / concat**
`append` 함수는 list에서 다룬 `@`과 동일한 역할을 합니다. 즉, 같은 type의 두 collection을 합쳐 새 collection으로 리턴합니다. 만일 세 개 이상의 collection에 대해 같은 일을 하고 싶다면, `concat` 함수를 사용하면 됩니다.
```fsharp
let list1 = List.append [1; 2; 3] [4; 5; 6]	// [1; 2; 3; 4; 5; 6]
let list2 = List.concat [[1; 2] [3; 4] [5; 6]]	// [1; 2; 3; 4; 5; 6]
```
`concat`함수는 인자의 개수가 정해지지 않았기 때문에 연결을 원하고자 하는 collection들의 list를 인자로 받습니다.

**7) 다른 collection으로의 변환**
지금까지 알아본 collection들은 상호 변환이 가능합니다.
`List.toSeq`는 list를 sequence로 바꾸며, `List.ofSeq`는 반대로 sequence를 list로 바꿔줍니다.
배열에 대해서도 `List.toArray`, `List.ofArray` 함수가 존재하며, 다른 collection들도 이에 대응하는 함수들이 전부 존재합니다.
`to*`함수와 `of*`함수는 성능상의 차이점은 전혀 없으니(즉, `List.ofSeq`와 `Seq.toList`는 내부적으로도 완벽하게 동일한 함수입니다.) 편하신 대로 사용하시면 됩니다.<br/><br/><br/>

지금까지 기본적인 collection에 대해 간략하게 알아보았습니다. 다음 글에서는 F#을 비롯한 FP 언어의 근본이라고 할 수 있는 함수에 대해 더 자세히 알아보겠습니다.