자바스크립트에서 배열은 Array 클래스의 인스턴스이며 다음처럼 선언한다.
let 배열 이름 = new Array(배열 길이);
다음 코드는 Array 클래스의 인스턴스를 만든 후 push 메서드를 이용해 [1, 2, 3]으로 구성된 배열을 만든다.
array.ts
let array = new Array
array.push(1); array.push(2); array.push(3);
console.log(array) // [1, 2, 3]
배열에 담긴 각각의 값을 아이템(item) 또는 원소(element)라고 하는데, 이 책에서는 아이템이라고 부르자. 즉, 앞 코드의 array 변수에는 3개의 아이템을 담고 있다.
앞에서 살펴본 array.ts 스타일로 배열을 만드는 것은 조금 번거롭다. 따라서 자바스립트는 [ ]라는 단축 구문을 제공한다. 다음 코드는 [ ] 단축 구문을 이용해 number 타입의 값으로 채운 numbers 배열과 string 타입의 값으로 채운 strings 배열을 만든다.
shorthand.ts
let numbers = [1, 2, 3];
let strings = ['Hello', 'World'];
console.log(numbes, strings); // [1, 2, 3]['Hello', 'World']
자바스크립트에 배열은 다른 언어와 다르게 객체이다. 배열은 Array 클래스의 인스턴스인데, 클래스의 인스턴스는 객체이기 때문이다. Array 클래스는 배열을 사용하는데 필요한 여러가지 메서드를 제공한다. 그 중 Array.isArray는 매개변수로 전달받은 심벌이 배열인지 객체인지 알려준다.
isArray.ts
let a = [1, 2, 3]
let o = {name : 'Jack', age : 32}
console.log({Array.isArray(a), Array.isArray(o)) // true false
타입스크립트에서 배열의 타입은 ‘아이템 타입[ ]’ 이다. 예를 들어, 밸열의 아이템이 number 타입이면 배열의 타입은 number [ ] 이고, 아이템이 string 타입이면 string [ ] 이다. 다음 코드는 배열에 타입 주석을 붙이는 방법을 나타낸다.
array-type.ts
let numArray : number[] = [1,2,3];
let strArray : string[] = ['Hello', 'World']
type IPerson = {name : string, age?: number}
let personArray : IPerson[] [{name : 'Jack'}, {name:'jane', age:32}]
어떤 프로그래밍 언어는 문자열을 문자들의 배열로 간주한다. 그러나 타입스크립트에서는 문자 타입이 없고 물자열의 내용 또한 변경할 수 없다. 이러한 특징 때문에 문자열을 가공하려면 먼저 문자열을 배열로 전환해야한다.
보통 문자열을 배열로 전환할 때는 String 클래스의 split 메서드를 사용한다. split 메서드는 문자열을 문자로 쪼개는 기준인 구분자를 입력받아 문자열을 string [] 배열로 만들어준다.
split(구분자:string): string[]
다음의 split 함수는 매개변수로 전달받은 문자열과 구분자를 이용해 String 클래스의 split 메서드를 호출함으로써 string[] 타입의 배열로 만들어 준다.
split.ts
export const split = (str:string, delim: string =''): string [] => str.split(delim)
다음은 split 함수를 테스트하는 코드로서 구분자를 생략한 예와 ‘_’를 사용한 예 두 가지를 보여준다.
split-test.ts
import {split} from'./split'
console.log(
split('hello);
split('h_e_l_l_o', '_')
)
string[] 타입의 배열을 다시 string 타입으로 변환하려면 Array 클래스의 join 메서드를 사용한다.
join(구분자: string): string
다음은 join 메서드를 이용하는 사용자 정의 함수 join을 작성한 예이다. join 함수는 매개변수로 전달받은 string[] 타입 배열과 구분자를 이용해 String 클래스의 join 메서드를 호출함으로써 문자와 구분자를 결합한 새 문자열을 반환한다.
join.ts
export const join = (strArray: string[], delim: string=''): string => strArray.join(delim)
다음은 join 함수에 string[]타입의 배열을 전달해서 구분자에 따라 어떤 문자열로 바뀌는지 보여주는 예이다.
join-test.ts
import {join} from './join'
console.log(
join(['h', 'e', 'l', 'l', 'o']), // hello
join(['h', 'e', 'l', 'l', 'o'], '_'), // h_e_l_l_o
)
배열이 담고 있는 아이템 중 특정 위치에 있는 아이템을 얻고자 할 때는 인덱스 연산자[인덱스]를 사용한다. 인덱스 연산자는 배열의 특정 위치에 있는 아이템을 얻는다.
다음 코드에서 05행은 numbers[index] 형태로 배열의 특정 위치(index)에 담긴 값을 얻는다.
array-index-operator.ts
const numbers: number[] = [1, 2, 3, 4, 5]
for(let index = 0; index< numbers.length; index++){
const item: number = numbers[index];
console.log(item) // 1 2 3 4 5
}
객체뿐만 아니라 배열에도 비구조화 할당을 적용할 수 있다. 배열의 비구조화 할당문에서는 객체와 달리 [] 기호를 사용한다. 다음 코드는 배열에 담긴 아이템을 비구조화 할당문으로 얻는다.
array-destructuing.ts
let array: number[] = [1,2,3,4,5]
let [first,second,third, ... rest] = array;
console.log(first, second, third, rest) /// 1 2 3 [4, 5]
ESNext 자바스크립트와 타입스크립트는 for 문을 좀 더 쉽게 사용하도록 for ... in 문을 제공합니다. for ... in 문은 객체를 대상으로 사용하지만, 앞서 설명한 것처럼 배열도 객체이므로 배열에 사용할 수도 있다.
for(변수 in 객체){
...
}
for ... in 문은 배열의 인덱스값을 순회
합니다. 다음 코드는 배열에 for ... in 문을 사용하는 예이다.
for-in.ts
let names= [ 'Jack', 'Jane', 'Steve'];
for(let index in names){
const name = names[index];
console.log(`[${index}]: ${name}`) // [0]: jack [1]: Jane [2]: Steve
}
만약, for ... in 문에 객체를 사용할 때는 객체가 가진 속성(property)을 대상으로 순회한다. 다음 코드는 name과 age 속성을 가진 jack 객체의 속성 이름과 값을 얻는 예이다.
object-for-in.ts
let jack ={name:'Jack', age:32}
for(let property in jack)
{
console.log(`${property}: ${jack[property]}`) // name: Jack, age: 32
}
ESNext 자바스크립트와 타입스크립트는 for ... in 과는 사용법이 약간 다른 for ... of 문도 제공한다.
for(let 변수 of 객체) {
...
}
for ... in 문은 배열의 인덱스 값을 대상으로 순회
하지만, for ... of 문은 배열의 아이템값을 대상으로 순회
한다. 다음 코드는 for...of 구문의 예로, 아이템값만 필요할 때는 for...in보다 좀 더 간결하게 구현할 수 있다.
for-of.ts
for(let name of ['Jack', 'Jane', 'Steve'])
console.log(name);
배열을 다루는 함수를 작성할 때는 number[]와 같이 타입이 고정된 함수를 만들기 보다는 T[] 형태로 배열의 아이템 타입을 한꺼번에 표현하는것이 편리하다. 타입을 T와 같은 일종의 변수(타입 변수)로 취급하는 것을 제네릭(generics)타입
이라 한다.
이제 자바스크립트 함수에 타입스크립트의 제네릭 타입을 사용하는 방법을 알아보자. 다음 arrayLength 함수는 배열의 길이를 얻는 함수로서 자바스크립트로 구현되었다.
const arrayLength = (array) => array.length
이 함수가 number[], string[], IPerson[] 등 다양한 아이템 타입을 가지는 배열에 똑같이 적용되게 하려면 다음처럼 배열의 타입 주석을 T[]로 표현한다.
const arrayLength = (array: T[]): number => array.length
그런데 이렇게 하면 컴파일러가 T의 의미를 알 수 있어야 한다. 즉, T가 타입 변수(type variable)
라고 알려줘야 한다. 예를 들어, 배열의 길이를 구하는 함수와 배열이 비었는지를 판별하는 함수를 제네릭 함수 스타일로 구현하면 다음과 같다.
arrayLength.ts
export const arrayLength = <T>(array: T[]): number => array.length
export const isEmpty = <T>(array: T[]): boolean => arrayLength<T>(array) == 0
다음 코드는 앞서 배열의 타입
절에서 본 array-type.ts의 코드에 위 두 함수를 적용한 예이다.
제네릭 함수로 구현했으므로 다양한 배열 타입에 모두 정상적으로 대응하는 것을 볼 수 있다.
arrayLength-test.ts
import {arrayLength, isEmpty} from './arrayLength'
let numArray : number[] = [1, 2, 3]
let strArray : string[] = ['Hello', 'World']
type IPesrson = {name: string, age?: number}
let personArray : IPerson[] = [{name: 'Jack'}, {name: 'Jane', age: 32}]
console.log(
arrayLength(numArray), // 3
arrayLength(strArray), // 2
arrayLength(personArray), // 2
isEmpty([]), // true
isEmpry([1]) // false
)
다음 코드에서 01행의 identity 함수는 제네릭 형태로 구현되었다.
generic-type-inference.ts
const identity = <T>(n: T): T => n
console.log(
identity<boolean>(true), // true
identity(true) //true
)
제네릭 형태로 구현된 함수는 원칙적으로는 03행처럼 타입 변수를 다음과 같은 형태로 명시해 주어야 한다.
함수이름<타입변수>(매개변수)
하지만 이런 코드는 번거로워서 타입스크립트는 04행처럼 타입 변수 부분을 생략할 수 잇게 하였다. 타입 스크립트는 타입 변수가 생략된 제네릭 함수를 만나면 타입 추론을 통해 생략된 타입을 찾아낸다.
04장에서 함수의 타입, 즉 함수 시그니처에 관해 알아보았다. 타입스크립트는 어떤 경우 함수 시그니처의 매개변수 부분에 변수 이름을 기입하라고 요구한다. 다음 화면에서 normal함수는 cb라는 이름의 매개변수에 함수 시그니처를 사용하였다. 그런데 normal과 달리 error는 묘하게 오류가 발생한다.
const normal = (cb: (number) => number): void => {}
const error = (cb: (number, number?) => number) : void => {}
const fixed = (cb: (a:number, number?) => number) : void => {}
함수 시그니처 오류
이런 오류가 발생하면 03행의 fixed 선언문 처럼 타입스크립트가 해석하지 못하는 부분에 변수를 삽입하고 이 변수에 타입을 명시에 해결한다. 제네릭 타입의 함수에서도 같은 문제가 발생하는데, 해결 방법은 fixed의 코드와 같다.
const f = <T>(cb: (arg: T, i?:number) => number) : void => {}
03장에서 점 세 개가 나란히 있는 ...을 전개 연산자(spread operator)라고 했다. 전개 연산자는 배열에도 적용할 수 있다. 다음 코드는 전개 연산자를 사용하여 두 개별과 특정 값을 동시에 결합하는 예이다.
spread-operator.ts
let array1 : number [] = [1];
let array2 : number [] = [2, 3];
let mergedArray : number [] = [...array1, ...array2, 4]
console.log(mergedArray) // [1, 2, 3, 4]
우리는 02-2절에서 ramda라는 외부 패키지가 제공하는 R.range란 함수를 잠시 사용해 보았다. 그런데 배열에 전개 연산자를 적용하면 R.range와 같은 함수를 쉽게 만들 수 있다.
다음 range 함수는 재귀함수 스타일로 동작하며, R.range처럼 from에서 to 까지 수로 구성된 배열을 생성해 준다.
range.ts
export const range = (from: number, to : number) : number[] => {
from < to ? [from, ...range(from + 1, to) : []
}
다음은 range 함수가 정상으로 동작하는지 알아보는 테스트 코드이다. range 함수에 1과 9+1을 전달했으므로 1부터 9까지 수로 구성된 배열을 반환한다.
range-test.ts
import {range} from './range'
let numbers: number[] = range(1, 9 + 1);
console.log(numbers)
이제 배열과 관련된 일반적인 내용을 알았으니 함수형 프로그래밍 관점에서 배열을 살펴보자.
이 책의 주제인 함수형 프로그래밍은 선언형 프로그래밍과 깊은 관련이 있다. 배열은 선언형 프로그래밍을 구현할 때 절대적으로 필요한 문법 기능이다. 선언형 프로그래밍은 명령형 프로그래밍과 비교되지만, 이 둘은 대등하게 비교할 대상은 아니다. 명형령은 좀 더 CPU 친화적인 저수준(low-level) 구현 방식이고, 선언형은 명령형 방식 위에서 동작하는 인간에게 좀 더 친화적인 고수준 구현 방식이다.
명령형과 선언형 프로그래밍을 구체적인 코드로 접하면서 배열을 어떤 방식으로 사용하는지 알아보자.
이번 절의 내용은 앞 절에서 구현한 range.ts파일이 필요하므로, ch05-1 디렉터리에서 range.ts 파일을 복사해 ch05-2 디렉터리에 가져오자.
프로그램의 기본 형태는 다음처럼 입력 데이터를 얻고 가공한 다음, 결과를 출력하는 형태로 구성된다.
- 입력 데이터 얻기
- 입력 데이터를 가공하여 출력 데이터 생성
- 출력 데이터 출력
명령형 프로그래밍에서는 여러 개의 데이터를 대상으로 할 때 다음처럼 for 문을 사용해서 구현한다.
for( ; ; ){
입력 데이터 얻기
입력 데이터 가공해 출력 데이터 생성
출력 데이터 출력
}
반면 선언형 프로그래밍은 시스템 자원의 효율적인 운용보다는 일관된 문제 해결 구조에 더 집중한다.
선언형 프로그래밍은 명령형 프로그래밍처럼 for문을 사용하지 않고 모든 데이터를 배열에 담고 문제가 해결될 때까지 끊임없이 또 다른 형태의배열로 가공하는 방식으로 구현한다.
- 문제를 푸는 데 필요한 모든 데이터 배열에 저장
- 입력 데이터 배열을 가공해 출력 데이터 배열 생성
- 출력 데이터 배열에 담긴 아이템 출력
이제 코드를 보면서 명령형과 선언형 프로그래밍의 차이, 그리고 배열의 역할에 관해 구체적으로 살펴보자.
다음 코드는 1부터 100까지 더하는 문제의 답을 for문을 사용해 구한다. 이러한 구조는 명령형 프로그래밍 방식이다.
imperative-sum.ts
let sum = 0;
for(let val = 1 ; val <= 100;){
sum += val++;
}
console.log(sum) // 5050
이번에는 선언형으로 구현해보자. 앞에서 명령형 코드는 데이터와 가공이 for문에서 이루어졌지만, 선언형은 데이터 생성과 가공 과정을 분리한다. 다음 코드는 일단 1부터 100까지 데이터를 배열로 생성한다.
declarative-sum.ts
import {range} from './range' // 05-1절에서 작성한 range.ts
let numbers : number[] = range(1, 100+1);
console.log(numbers) // [1, 2, ..., 100]
이제 우리는 배열에 담긴 데이터를 모두 더해야 한다. 이와 같은 방식의 데이터 가공은 함수형 프로그래밍에서는 흔히 만날 수 있는 ‘폴드’라고 부르는 함수를 사용한다.
함수형 프로그래밍에서 폴드(fold)
는 특별한 의미가 있는 용어이다. 폴드는 [1, 2, 3, ...] 형태의 배열 데이터를 가공해 5050과 같은 하나의 값을 생성하려고 할 때 사용
한다. 배열의 아이템 타입이 T라고 할 때 배열은 T[ ]로 표현할 수 있는데, 폴드 함수는 T[ ] 타입 배열을 가공해 T 타입 결과를 만들어 준다. 폴드 함수의 이런 동작 방식은 마치 부채처럼 배열을 펼쳐 놓은 다음, 부채를 접어서(fold) 결과를 만들어 내는 것으로 생각할 수 있습니다.
다음 코드에서 fold 함수는 T 타입의 배열 T[]를 가공해 타입 T의 결괏값을 만듭니다.
fold.ts
export const fold = <T>(array:T[], callback: (result: T, val: T) => T, initValue : T) =>
{
let result : T = initValue;
for(let i = 0; i < array.length; ++i){
const value = array[i];
result = callback(result, value)
}
return result ;
}
이제 fold 함수를 사용해 선언형 프로그래밍 방식으로 1부터 100까지 더하는 코드를 구현해보자. 다음 코드에서 07행은 1부터 100까지 숫자가 담긴 배열과 배열에 담긴 수를 더하는 콜백 함수를 fold함수에 전달해 5050이라는 답을 얻는다.
declarative-sum.ts
import {range} from './range'
import {fold} from './fold'
// 입력 데이터 생성
let numbers: number[] = range(1, 100 + 1);
// 입력 데이터 가공
let result = fold(numbers, (result, value) => result + value, 0)
console.log(result) // 5050
앞에서 작성한 imperative-sum.ts와 declarative-sum.ts의 코드를 비교해보면 결과는 같지만 문제 해결 방식에 차이가 있다. 명령형 방식은 시스템 자원의 효율을 최우선으로 생각하지만, 선언형 방식은폴드처럼 범용으로 구현된(혹은 언어가 제공하는) 함수를 재사용 하면서 문제를 해결한다.
이제 범용적이고 재사용 가능
이라는 관점에서 또 다른 문제를 풀어보자. 1에서 100까지 숫자 중 홀수만 더하는 문제이다. 다음은 이를 명령형 방식으로 구현한 코드이다. 이 방식은 1부터 시작해 값을 2씩 증가시키면 홀수를 만들 수 있다는 경험에 의존한다.
imperative-odd-sum.ts
let oddSum = 0;
for(let val = 1; val <= 100; val += 2)
oddSum += val
console.log(oddSum) // 2500
이제 선언형 방식의 코드를 생각해 보겠습니다. 앞서 구현한 fold는 배열 데이터를 값으로 만들어 주는 기능을 수행할 뿐 배열 데이터에서 홀수만 추려내는 기능은 없습니다. 따라서 특정한 조건을 만족하는 아이템만 추려내는 filter라는 함수를 먼저 만들겠습니다.
함수형 프로그래밍에서 흔히 보이는 filter라는 이름의 함수는 입력 배열을 가공해 조건에 맞는 값만 추려내는 기능을 합니다. 예를 들어, [1, 2, 3, ..., 100] 배열에 필터를 적용해 val % 2 != 0인 조건을 만족하는 아이템만 추려내면 [1, 3, 5 ..., 99]라는 홀수만 있는 배열을 만들 수 있습니다. 다음은 filter 함수를 구현한 예입니다.
filter.ts
export const filter = <T>(array: T [], callback: (value:T, index?: number) => boolean): T[]=>{
let result : T[] =[];
for(let index: number = 0; index< array.length; ++index)
{
const value = array[index];
if(callback(value,index))
result = [...result, value];
}
return result;
}
이제 filter를 사용해 앞서 구현했던 declarative-sum.ts의 내용을 조금 수정해 declarative-odd-sum.ts를 작성합니다.
declarative-odd-sum.ts
import { range } from "./range";
import { fold } from "./fold";
import { filter } from "./filter";
let numbers: number[] = range(1, 100 + 1);
const isOdd = (n: number): boolean => n % 2 != 0;
let result = fold(filter(numbers, isOdd), (result, value) => result + value , 0);
console.log(result)
declarative-sum.ts에서는 단순히 1부터 100까지 데이터가 담진 numbers 배열만 대상으로 하지만, 이 코드는 numbers를 filter 함수를 사용해 홀수만 추려낸 다음, 비로소 배열의 합을 구하는 로직을 적용한다.
이러한 문제 해결 방식은 마치 컨베이어 벨트에 물건들이 차례차례 이동하는 듯한 흐름을 보여준다. 이제 이와 유사한 또 다른 문제를 풀어보자.
1에서 100까지 홀수의 합이 2,500이라면 짝수의 합은 5,050 - 2,500 = 2,550이어야 한다. 명령형 방식으로 이를 증명하는 코드를 작성해보자. 다음 코드는 0부터 시작해 2씩 증가시키는 방식으로 짝수를 얻는데, 이 역시 경험에 의존한 구현이다.
imperative-even-sum.ts
let evenSum = 0;
for(let val = 0; val <= 100; val +=2)
evenSum += val
console.log(evenSum) // 2550
반면에 다음은 선언형 방식으로 짝수의 합을 구합니다. 앞의 declarative-odd-sum.ts의 내용과 비교할때 isEven 부분만 다릅니다. 즉, 이미 구현해 준 fold와 filter 함수를 재사용하고 구현 로직도 재사용 합니다.
declarative-event-sum.ts
import { range } from "./range";
import { fold } from "./fold";
import { filter } from "./filter";
let numbers: number[] = range(1, 100 + 1);
const isEven= (n: number): boolean => n % 2 == 0;
let result = fold(filter(numbers, isEven), (result, value) => result + value , 0);
console.log(result)
이번엔 입력 데이터 자체를 모두 가공하는 형태의 문제를 풀어보겠습니다. 다음 코드는 배열의 각 아이템을 곱한 뒤 모두 더하는 계산을 명령형 방식으로 구현한 것이다.
imperative-square-sum.ts
let squareSum = 0;
for(let val = 1; val <= 100; ++val)
squareSum += val * val
console.log(squareSum) //338350
선언형 방식으로 입력 데이터를 이와 같이 구현하려면 map이라는 이름의 함수가 필요하다.
선언형 방식으로 이 문제의 답을 구하려면 [1, 2, ...] 형태의 입력 데이터를 [12, 22, ...] 형태로 가공해 주는 함수가 필요합니다. 이런 기능을 구현하려면 보통 map이라는 이름의 함수를 이용한다.
수학에서 map은 ‘x ~> y’ 형태로 어떤 값을 또 다른 값으로 만들어 주는 연산을 의미
한다.
그런데 변수 x와 y의 타입까지 생각하면 map은 ‘x : T ~> y : Q’ 처럼 입력과 출력 변수의 타입이 서로 다를 수 있음을 고려
해야 한다.
다음은 입력 타입 T가 출력 타입 Q로 바뀔 수 있다는 전제로 map 함수를 구현한 예이다.
map.ts
export const map = <T, Q>(array: T[], callback: (value: T, index?: number) => Q): Q[] => {
let result: Q[] = [];
for (let index = 0; index < array.length; ++index) {
const value = array[index];
result = [...result, callback(value, index)];
}
return result;
}
이제 map 함수를 이용하면 선언형 방식의 코드를 다음처럼 작성할 수 있다.
declarative-square-sum.ts
import { range } from "./range";
import { fold } from "./fold";
import { map } from "./map";
let numbers: number[] = range(1, 100 + 1);
let result = fold(
map(numbers, value => value * value),
(result, value) => result + value, 0)
console.log(result)
지금까지 fold, filter, map과 같은 함수를 만들면서 선언형 프로그래밍의 개념을 알아보았다. 그런데 사실 타입스크립트 배열은 이런 함수들이 메서드형태로 이미 구현되어 있다. 다음 절에서는 타입스크립트의 배열이 제공하는 메서드를 알아보자.
앞서 04-6절에서 클래스의 메서드 체인에 대해 살펴보았다. 배열 또한 이런 메서드 체인 방식으로 동작하도록 설계되었다. 다음 코드에서 04~07행은 앞으로 자주 볼 전형적인 메서드 체인 방식이다.
array-method-chain.ts
const multiply = (result, val) => result * val //07행에서 사용
let numbers: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
let tempResult = numbers
.filter(val => val % 2 != 0)
.map(val => val * val)
.reduce(multiply, 1);
let result = Math.round(Math.sqrt(tempResult))
console.log(result) // 945;
배열의 타입이 T [ ] 일떄 배열의 filter 메서드는 다음과 같은 형태로 설계되었다.
filter(callback: (value: T, index?: number): boolean): T[]
배열의 타입이 T [ ]일때 배열의 map메서드는 다음과 같은 형태로설계되었다. filter와 달리 map 메서드는 입력 타입과 다른 타입의 배열을 만들수 있다.
map(callback:(value: T, index?: number): Q) Q[]
닾서 05-2절에서 구현한 fold 함수는 타입스크립트 배열의 reduce 메서드로 대체할 수 있다. 배열의 타입이 T[]일때 태열의 reduce메서드는 다음과 같은 형태로 설계되었다.
reduce(callback : (result: T, value: T), initalValue: T) : T
함수형 프로그래밍에서 함수는 순수 함수 (pure function)
라는 조건을 만족해야 한다.
그러나 타입스크립트의 Array 클래스에는 순수 함수 조건에 부합하지 않는 메서드가 많다.
따라서 타입스크립트로 함수형 프로그래밍을 하면서 배열의 메서드를 사용할 때는 해당 메서드가 어떤 특성이 있는지 살펴야 한다.
순수 함수는 부수 효과(side-effect)가 없는 함수
를 말한다. 여기서 부수 효과란 함수가 가진 고유한 목적 이외에 다른 효과가 나타나는 것을 의미하며 부작용이라고도 한다. 반면에 부수 효과가 있는 함수는 불순 함수(impure function)
라고한다.
함수형 프로그래밍에서 발생하는 부수 효과는 함수를 순수 함수 형태로 작성해야만 제거할 수 있다. 어떤 함수가 부수 효과가 없는 순수한 함수이려면 다음과 같은 조건을 충족해야한다.
- 함수 몸통에 입출력 관련 코드가 없어야 한다.
- 함수 몸통에서 매개변수값을 변경시키지 않는다.
(즉, 매개변수는 const나 readonly 형태로만 사용한다)
- 함수는 몸통에서 만들어진 결과를 즉시 반환한다.
- 함수 내부에 전역 변수나 정적 변수를 사용하지 않는다.
- 함수가 예외를 발생시키지 않는다.
- 함수가 콜백 함수로 구현되었거나, 함수 몸통에 콜백 함수를 사용하는 코드가 없다.
- 함수 몸통에 Promise와 같은 비동기 방식으로 동작하는 코드가 없다.
예를 들어, 다음 pure 함수는 이런 조건을 모두 만족하는 순수 함수이다.
function pure(a: number, b: number): number { return a + b }
그러나 다음 impure1 함수는 매개변수를 변경하므로 부수 효과가 발생한다. impure1 함수 몸통에서 array 매개변수로 전달받은 배열은 push와 splice 메서드를 호출함으로써 내용이 달라진다.
즉, 매개변수가 readonly 형태로 동작하지 않으므로 불순 함수이다.
function impure1(array: number[]): void{
array.push(1)
array.splice(0, 1)
}
다음 impure2 함수는 g라는 외부 변수를 사용하므로 불순 함수이다.
let g = 10;
function impure2(x:number) {return x + g}
타입스크립트는 순수 함수 구현을 쉽게 하도록 readonly 키워드를 제공한다. readonly 타입으로 선언된 매개변수값을 변경하는 시도가 있으면 다음처럼 문제가 있는 코드라고 알려줘서 불순 함수가 되지 않게 방지한다.
그런데 얼핏 const 키워드가 있는데 또 readonly가 필요한가?
라는 의문이 들 수 있다.
타입스크립트에서 인터페이스, 클래스, 함수의 매개변수 등은 let이나 const 키워드 없이 선언한다. 따라서 이런 심벌에 const와 같은 효과를 주려면 readonly라는 타입 수정자가 필요하다.
변수가 const나 readonly를 명시하고 있으면 변숫값은 초깃값을 항상 유지한다. 이런 변수는 변경할 수 없다는 의미로 불변(immutable)
변수라고 한다. 반면 const나 readonly를 명시하지 않은 변수는 언제든 값을 변경할 수 있다. 이런 변수는 변경할 수 있다는 의미로 가변(mutable)
변수라고 한다.
프로그래밍 언어에서 어떤 변수값을 다른 변수값으로 설정하는 것을 복사(copy) 라고 표현한다.
복사에는 깊은 복사(deep-copy)
와 얕은 복사(shallow-copy)
두 종류가있다. 순수 함수를 구현할 때는 매개변수가 불변성을 유지해야 하므로, 매개변수를 가공하려고 할 때 깊은 복사를 실행
하여 매개 변수값이 변경되지 않게 해야 한다.
깊은 복사는 대상 변수값이 바뀔 때 원본 변수값은 그대로인 형태로 동작한다. 다음 코드는 깊은 복사의 예이다.
deep-copy.ts
let original = 1;
let copied = original;
copied += 2;
console.log(original, copied) // 1 3
02행의 copied 변수는 01행의 original 변수값을 복사 한 뒤 03행에서 2를 더한다. 이 때 original 변수값은 변하지 않는다. 이것이 깊은 복사이다. 타입스크립트에서 number와 boolean 타입은 깊은 복사 형태로 동작한다.
그러나 객체와 배열은 얕은 복사 방식으로 동작한다. 다음 코드는 deep-copy.ts의 내용과 같지만 복사 대상의 타입이 배열이다. 코드를 실행해 보면 얕은 복사가 된 shallowCopiedArray가 내용을 변경하면 원본 배열 또한 변경되는 것을 확인할 수 있다.
shallow-copy.ts
const originalArray= [5, 3, 9, 7];
const shallowCopiedArray = originalArray;
shallowCopiedArray[0] = 0
console.log(originalArray, shallowCopiedArray) // [0, 3, 9, 7] [0, 3, 9, 7]
전개 연산자를 사용해 배열을 복사하면 깊은 복사를 할 수 있다. 다음 코드는 02행에서 전개 연산자를 사용해 배열을 복사하였다. 그리고 앞에서와 같이 대상 배열의 값을 변경했지만 원본 배열은 변경되지 않은 것을 확인할 수 있다.
deep-copy-by-spread-operator.ts
const oArray = [1, 2, 3, 4];
const deepCopiedArray = [...oArray];
deepCopiedArray[0] = 0;
console.log(oArray, deepCopiedArray) // [1, 2, 3, 4] [0, 2, 3, 4]
Array 클래스는 sort 메서드를 제공해 배열의 아이템을 오름차순(ascend) 혹은 내림차순(descend)으로 정렬해 준다. 그런데 sort 메서드는 원본 배열의 내용을 변경한다. 다음 pureSort함수는 readonly 타입으로 입력 배열의 내용을 유지한 채 정렬할 수 있도록 전개 연산자의 깊은 복사 기능을 사용 하였다.
pureSort.ts
export const pureSort = <T>(array: readonly T[]) : T[] =>{
let deepCopied = [...array];
rturn deepCopied.sort();
}
다음 테스트 코드는 원본 배열을 변경하지 않으면서 내용이 정렬된 새로운 배열을 얻는다.
pureSort-test.ts
import [pureSort} from './pureSort'
let beforeSort = [6, 2, 9, 0];
const afterSort = pureSort(beforSort);
console.log(beforeSort, afterSort) // [6, 2, 9, 0] [0, 2, 6, 9]
배열에서 특정 아이템을 삭제할 때는 splice 메서드를 사용
한다. 그런데 splice는 원본 배열의 내용을 변경하므로 순수 함수에서는 사용할 수 없다. 그런데 흥미롭게도 특정 아이템을 삭제하는데 filter 메서드를 사용할 수 있다. 배열이 제공하는 filter와 map 메서드는 sort와 다르게 깊은 복사 형태로 동작한다. 따라서 filter 메서드를 사용하면 원본 매열의 내용을 훼손하지 않으면서 조건에 맞지 않는 아이템을 삭제할 수 있다.
예를 들어, filter 메서드를 활용해 원본을 훼손하지 않고 조건에 맞는 아이템을 삭제하는 함수(pureDelete)를 다음처럼 작성할 수 있다.
pureDelete.ts
export const pureDelete = <T>(array: readonly T[], cb: (val: T, index?: number) =>
boolean): T[] => array.filter((val, index) => cb(val, index) == false)
pureDelete-test.ts
import { pureDelete } from './pureDelete';
const mixedArray: object[] = [
[], { name: 'Jack' }, { name: 'Jane', age: 32 }, ['description']
]
const objectsOnly: object[] = pureDelete(mixedArray, (val) => Array.isArray(val))
console.log(mixedArray);
console.log(objectsOnly)
실행결과
함수를 호출할 때 전달하는 인수의 개수를 제한하지 않은 것을 가변 인수(variacdic arguments)라고 한다. 다음 코드에서 mergeArray 함수는 03행에서 두 개의 인수를 입력받고, 08행에서는 4개의 인수를 입력 받는다.
mergeArray처럼 이런 방식으로 동작하는 함수를 ‘가변 인수 함수’라고 한다.
mergeArray-test.ts
import { mergeArray } from './mergeArray'
const mergedArray1: string[] = mergeArray(['Hello'], ['World'])
console.log(mergeArray1) // ['Hello', 'World']
const mergedArray2: number[] = mergeArray(
[1], [2, 3], [4, 5, 6], [7, 8, 9, 10]
)
console.log(mergeArray2) // [1,2,3,4,5,6,7,8,9,10]
이처럼 가변 인수로 호출할 수 있는 mergeArray를 구현해보자. 가변 인수 함수를 구현할 때 기본형태는 다음과 같다. 매개변수 arrays 앞의 ...은 잔여나 전개 연산자가 아니라 가변 인수를 표현하는 구문이다.
export const mergeArray = (...arrays) => {}
앞서 본 mergeArray-test.ts에서 mergeArray함수는 string[] 타입과 number[] 타입 배열에 모두 동작하였다. mergeArray 함수가 이처럼 타입에 상관없이 동작하게 하려면 다음처럼 제네릭 타입으로 구현해야 한다.
export const mergeArray = <T>(...arrays) => {}
또한. mergeArray-test.s에서 mergeArray 함수를 호출할 때 전달하는 값은 모두 배열이었다. 따라서 매개변수 arrays 타입은 배열의 배열로 선언한다.
export const mergeArray = <T>(...arrays : T[][]) => {}
mergeArray 함수의 매개변수 arrays는 배열의 배열인 T[][]타입일지라도 출력은 T[] 형태의 배열을 반환해야 한다.
export const mergeArray = <T>(...arrays : T[][]) : T[] => {}
마지막으로 mergeArray함수를 순수 함수
로 구현하려면 매개변수의 내용을 훼손하지 말아야 한다.
export const mergeArray = <T>(...arrays : readonly T[][]) => {}
지금까지 살펴본 내용을 바탕으로 mergeArray함수를 구현하면 다음과 같다.
mergeArray.ts
export const mergeArray = <T>(...arrays: readonly T[][]): T[] => {
let result: T[] = [];
for (let index = 0; index < arrays.length; index++) {
const array: T[] = arrays[index]
/* result와 array 배열을 각각 전개(spread)하고 결합(merge)해야 T[] 타입 배열을
생성할 수 있다 */
result = [...result, ...array]
}
return result
}
순수 함수를 고려하면 사실상 자바스크립트 배열이 제공하는 많은 메서드를 사용 할 수없다.
그런데 이런 메서드들은 전개 연산자 드으이 메커니즘을 사용하면 순수 함수 형태로 간단하게 구현할 수 있다.
어떤 프로그래밍 언어에는 튜플(tuple)
이라는 타입이 존재한다. 그러나 자바스크립트에는 튜플이 없으며 단순히 배열의 한 종류로 취급된다. 다음은 여러 타입에 대응하는 any 타입 배열을 선언한 예이다.
let tuple : any[] = [true, 'the result is ok']
그런데 any[] 형태는 타입스크립트의 타입 기능을 무력화 하므로, 타입스크립트는 튜플의 타입 표기법을 배열과 다르게 선언할 수 있다.
const array: number[] = [1, 2, 3, 4, 5];
const tuple : [boolean, string] = [true,'the result is of']
보통 튜플을 사용할 때는 타입 별칭(alias)으로 튜플의 의미를 명확하게 합니다. 예를 들어, [boolean, string] 이라고 타입을 지정하는 것보다 다음처럼 타입 별칭을 사용해 이 튜플이 어떤 용도로 사용되는지 좀 더 분명하게 알려주는 것이 좋다.
ResultType.ts
export type ResultType = [boolean, string]
다음 코드에서 doSomething 함수는 자바스크립트의 try/catch/finally 예외 처리 구문을 사용해 예외(exception)가 발생했을 때 구체적인 내용을 튜플로 반환한다. 이 때 doSomething 함수가 반환하는 튜플의 타입을 앞에서 [booelan, string]의 벼ㅑㄹ칭으로 정의한 ResultType으로 지정했다.
doSomething.ts
import { ResultType } from './ResultType'
export const doSomething = (): ResultType => {
try {
throw new Error('Some error occurs...')
} catch (e) {
return [false, e.message]
}
// catch (e: unknown) {
// const { message } = e as Error;
// return [false, message]
// }
}
이러한 예외 처리 코드는 불순한 함수를 순수 함수로 바꿔주는 전형적인 코드 설계 방식이다.
튜플은 물리적으로는 배열이므로 배열처럼 인덱스 연산자나 비구조화 할당문을 적용할 수 있다. 다음 코드는 03행에서 비구조화 할당문을 사용해 앞에서 정의한 doSomething 함수가 반환한 튜플의 내용을 자세하게 알려준다.
doSomething-test.ts
import {doSomething} from './doSomething'
const [result, errorMessage] = doSomething();
console.log(result, errorMessage) // false Some error occurs...
함수형 프로그래밍에서 배열은 가장 핵심적인 기능이다. 다음 장에서는 배열처럼 동작하면서 메모리를 효율적으로 사용하게 하는 생성기(generator)에 대해 알아보겠습니다.