재귀는 큰 목표 작업 하나를 동일하면서 간단한 작업 여러 개로 나눌 수 있을 때 유용한 프로그래밍 패턴이다.
목표 작업을 간단한 동작 하나와 목표 작업을 변형한 작업으로 단순화시킬 수 있을 때도 재귀를 사용할 수 있다.
또한 특정 자료구조를 다뤄야 할 때도 재귀가 사용된다.
문제를 해결하다 보면 함수에서 다른 함수를 호출해야 할 때가 있다.
이때 함수가 자기 자신을 호출할 수도 있는데, 이를 재귀라고 부른다.
재귀의 간단한 예시
x를 n제곱해 주는 함수 pow(x,n)를 만들어보자
구현하는 방법은 두 가지가 있다
1. 반복적인 사고를 통한 방법: for 루프
function pow(x, n) {
let result = 1;
// 반복문을 돌면서 x를 n번 곱함
for(let i = 0; i < n; i ++) {
result *= x;
}
return result;
}
alert ( pow (2,3) ) // 8
function pow(x,n) {
if (n == 1) {
return x;
} else {
return x * pow( x, n-1);
}
}
재귀 사용한 예시와 반복문을 사용한 예시와 어떤 부분에서 차이가 있는지 알아보자
1. n == 1일 때 : 모든 절차가 간단해진다. 명확한 결괏값을 즉시 도출하므로 이를 재귀의 베이스 라고한다.
2. n == 1이 아닐 때: pow(x,n)은 x * pow (x, n-1) 로 표현할 수 있다. 이를 재귀단계라고 부른다.
즉, pow는 n == 1이 될 때가지 재귀적으로 자신을 호출한다.
이렇게 재귀를 이용하면 함수 호출의 결과가 명확해질 때까지 함수 호출을 더 간단한 함수 호출로 계속 줄일 수 있다.
또한 재귀를 사용한 코드는 반복적 사고에 근거하여 작성한 코드보다 대게 짧다.
function pow (x,n) {
return (n == 1) ? x : (x * pow(x , n-1));
}
중첩 호출의 최대 개수는 재귀 깊이라고 한다. pow(x,n)의 재귀 깊이는 n이다.
자바스크립트 엔진은 최대 재귀 깊이를 제한한다.
만개 정도 까지는 확실히 허용하고, 십만까지는 다루지 못한다.
이러한 재귀 깊이 제한 떄문에 재귀를 실제 적용하는데 제약이 있긴 하지만, 재귀는 여전히 광범위하게 사용되고있다.
재귀를 사용함녀 간결하고 유지보수가 쉬운 코드를 만들 수 있기 때문이다.
실제 재귀 호출이 어떻게 동작하는지 알아보자
실행 중인 함수의 실행 절차에 대한 정보는 해당 함수의 실행 컨텍스트(execution context)에 저장된다.
실행 컨텍스트는 함수 실행에 대한 세부 정보를 담고 있는 내부 데이터 구조이다. 제어 흐름의 현재 위치, 변수의 현재 값, this의 값 등 상세 내부 정보가 실행 컨텍스트에 저장된다.
함수 호출 일 회당 하나의 실행 컨텍스트가 생성된다.
함수 내부에 중첩 호출이 있을 때는 아래와 같은 절차가 수행된다.
function pow(x,n) {
if ( n == 1 ) {
return x;
} else {
return x * pow(x, n-1);
}
}
pow(2,1)의 실행 컨텍스트
Context : {x: 2, n:1, 첫 번째 줄}
Context: {x:2, n:2, 다섯 번째 줄}
Context: {x:2, n:3, 다섯 번째 줄}
위에서부터 차례차례 스택의 컨텍스트를 꺼내서 실행한다.
지금 본 예시의 재귀의 깊이는 3이다.
반복문을 상용해 만든 함수 pow
function pow(x,n) {
let result = 1;
for(let i = 0; i < n; i ++ ){
result *= x
}
return result;
}
반복문을 사용해 pow를 만듦녀 컨텍스트를 하나만 사용한다. 이 컨텍스트에서 i와 result가 변경된다.
실행 컨텍스트가 하나이기 때문에 n에 의존적이지 않고, 필요한 메모리가 적다. 사용 메모리 공간도 고정된다.
**반복문을 사용하면 대개 함수 호출의 비용(메모리 사용)이 절약된다.
하지만 재귀를 사용하면 코드가 짧아지고 코드 이해도가 높아지며 유지보수에도 이점이 있다.
모든 곳에서 메모리 최적화르 ㄹ신경써서 코드를 작성해야하는 것은 아니다
우리가 필요한것은 좋은 코드이다. 이런 이유 때문에 재귀를 사용한다.
객체를 정렬하여 어딘가에 저장하고 싶을 때
let arr = [obj1, obj2, obj3];
하지만 배열은 요소 삭제와 삽입에 들어가는 비용이 많이 든다는 문제가 있다.(unshift, shift)
빠르게 삽입 혹은 삭제를 해야할 때는 배열 대신 연결 리스트라 불리는 자료 구조를 사용할 수 있다.
연결 리스트의 요소는 객체와 아래 프로퍼티들을 조합해 정의할 수 있다.
let list = {
value: 1,
next: {
value: 2,
next: {
value: 3,
next: {
value: 4,
next: null
}
}
}
}
아래처럼 코드를 작성해도 동일한 연결리스트가 된다.
let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list next.next.next = { value: 4 };
list.next.next.next.next = null;
연결 리스트를 사용하면 전체 리스트를 여러 부분으로 쉽게 나눌 수 있고, 다시 합치는 것도 가능하다.
let secondList = list.next.next;
list.next.next = null;
그리고 쉽게 요소를 추가하거나 삭제할 수 있다.
리스트의 처음 객체를 바꾸면 리스트 맨 앞에 새로운 값을 추가할 수 있다.
주어진 숫자까지의 모든 숫자 더하기
숫자 1 + 2 + .. + n을 계산하는 함수 sumTo (n)을 만들어보세요
function sumTo(n) {
let sum = 0;
for(let i = n; i >= 1; i-- ) {
sum += n;
}
return sum;
}
function sumTo(n) {
let sum = 0;
if(n==1) {
return 1;
} else {
return n + sumTo(n-1)
}
}
function sumTo(n) {
return n * (n + 1) / 2;
}
등차수열의 합공식을 사용하는 방법이 가장 빠르고, 두 번째로는 반복을 사용하는 방법이다.
재귀를 사용하는 방법은 중첩 호출과 실행 스택 관리가 추가로 필요하기 떄문에 더 많은 자원을 소비한다.
따라서 속도가 더 느리다.
팩토리얼 계산하기
n이 자연수일 때, 1부터 n까지의 모든 자연수의 곱을 의미
n팩토리얼은 n!으로 표시
n! = n * (n-1) * (n - 2) * ... * 1
재귀를 사용하여 n!을 계산하는 함수 factorial(n)을 만들어보세요
function factorial(n) {
if(n == 1) {
return 1;
} else {
return n * factorial(n-1);
}
}
단일 연결 리스트 출력하기
재귀와 스택에서 설명한 바 있는 단일 연결 리스트가 있다고 가정해봅시다.
let list = {
value: 1,
next: {
value: 2,
next: {
value: 3,
next: {
value: 4,
next: null
}
}
}
};
리스트 내 항목을 차례대로 하나씩 출력해주는 함수 printList(list)를 만들어보세요
반복문과 재귀를 사용한 답안 각각을 만들어봅시다.
그리고 재귀를 사용한 것과 재귀를 사용하지 않은 것 중 어떤 게 더 좋은 코드인지 생각해보자
해답
function printList(list) {
let tmp = list;
while(tmp) {
alert(tmp.value);
tmp = tmp.next;
}
}
또다른 답
function printList(list) {
while(list) {
alert(list.value);
list = list.next;
}
}
하지만 매개변수 list를 바로 사용하는 건 그다지 현명한 선택은 아니다.
나중에 함수를 확장할 때 list를 가지고 뭔가 해야하는 경우가 생길 수 있기 때문이다.
while문 앞에서 list가 변경되면 위의 코드느 제대로 동작하지 않을것이다.
좋은 변수 명이 무엇인가를 생각해 봤을 때도 리스트를 임시 변수 tmp에 저장하는 게 좋다.
list 에는 그 리스트 그 자체가 저장되어 있는게 좋다.
tmp는 리스트를 순회하기 위한 용도로 쓰였기 떄문에 tmp라고 명명해는게 좋다. for문의 i처럼
재귀를 기반으로 한 답
function printList(list) {
alert(list.value) // 현재 요소를 출력
if(list.next) {
printList(list.next); // 같은 방법을 사용해 나머지 요소를 출력
}
}
상당수의 자바스크립트 내장 함수는 인수의 개수에 제약을 두지 않는다.
예시:
함수 정의 방법과 상관없이 함수에 넘겨주는 인수의 개수엔 제약이 없다.
function sum(a,b) {
return a + b;
}
alert( sum (1,2,3,4,5) );
함수를 정의할 땐 인수를 두 개만 받도록 하고, 실제 함수를 호출할 땐 이보다 더 많은 여분의 인수를 전달했지만, 에러가발생하지 않았다.
다만 반환 값은 처음 두 개의 인수만을 사용해 계산된다.
이렇게 여분의 매개변수는 그 값들을 담을 배열 이름을 마침표 세 개 ...뒤에 붙여주면 함수 선언부에 포함시킬 수 있다.
이때 마침표 세개 ...는 "남아있는 매개변수들을 한데 모아 배열에 집어넣어라." 는 것을 의미한다.
나머지 매개변수는 항상 마지막에 있어야한다.
유사 배열 객체인 arguments를 사용하면 인덱스를 사용해 인수에 접근할 수 있다.
또한 arguments 는 이터러블 객체이기 때문에 for(let arg of arguments) alert(arg)를 사용해 인수를 펼칠 수 있다.
arguments는 유사 배열 객체이면서 이터러블(반복 가능한)객체이다.
하지만 배열은 아니다 따라서 배열 메서드를 사용할 수 없다는 단점이 있다
또한 인수 전체를 담기 때문에 나머지 매개변수처럼 인수의 일부만 사용할 수 없다는 단점도 있다.
따라서 배열 메서드를 사용하거나 인수 일부만 사용할 때는 나머지 매개변수를 사용하는 것이 좋다.
또한 화살표 함수는 arguments 객체를 지원하지 않는다.
접근하면, 외부에 있는 일반 함수의 arguments객체를 가져온다.
개발을 하다보면 배열을 통째로 매개변수에 넘겨주는 기능이 필요할때가 있다.
예를들어 내장함수 Matn.max는 인수로 받은 숫자 중 가장 큰 숫자를 반환한다.
alert(Matn.max(3,5,1)); // 5
배열 [3,5,1]이 있고 이 배열을 대상으로 Matn.max를 호출하고 싶다고 가정해보자
아무런 조작 없이 배열을 있는 그대로 넘기면 원하는 대로 동작하지 않는다. (결과는 NaN)
Math.max는 배열이 아닌 숫자 목록을 인수로 받기 때문이다.
Math.max(arr[0],arr[1],arr[2]) 처럼 배열 요소를 수동으로 나열하는 방법도 있긴 한데, 배열 길이를 알 수 없을 때는 이마저도 불가능하다.
스크립트가 돌아갈 때 넘어오는 배열의 길이는 아주 길 수도 있고, 아예 빈 배열일 수도 있기 때문이다.
전개 구문(spread syntax, 스프레드 구문)은 이럴 떄 사용하기 위해 만들어졌다.
...를 사용하기 때문에 나머지 매개변수와 비슷해보이지만 전개 구문은 나머지 매개변수와 반대 역할을 한다.
함수를 호출할 때 ...arr을 사용하면, 이터러블 객체 arr이 인수 목록으로 '확장'된다.
let arr = [3,5,1];
alert( Math.max(...arr) ) // 5 (전개 구문이 배열을 인수 목록으로 바꿔주었음.)
아래와 같이 이터러블 객체 여러 개를 전달하는 것도 가능하다.
let arr1 = [1,-2,3,4];
let arr2 = [8,3,-8,1];
alert(Math.max(...arr1,...arr2); // 8
스프레드 문법을 평범한 값과 혼합해 사용하는 것도 가능히다.
let arr1 = [1,-2,3,4];
let arr2 = [8,3,-8,1];
alert(Math.max(1,...arr1, 2, ...arr2, 25); // 25
전개 구문은 배열을 합칠 때도 활용할 수 있다.
let arr = [3,5,1];
let arr2 = [8,9,15];
let merged = [0, ...arr, 2, ...arr2];
alert(merged); // 0,arr,2,arr2순서로 합쳐진다.
앞선 예시들에선 배열을 대상으로 전개 구문이 어떻게 동작하는지를 살펴보았다.
그런데 배열이 아니더라도 이터러블 객체이면 전개 구문을 사용할 수 있다.
스프레드 문법을 사용해 문자열을 문자 배열로 변환시켜보자
let str = "Hello";
alert([...str]; // H,e,l,l,o
전개 구문은 for..of와 같은 방식으로 내부에서 이터레이터(iterator, 반복자)를 사용해 요소를 수집한다.
문자열에 for..of를 사용하면 문자열을 구성하는 문자가 반환된다.
...str도 H,e,l,l,o 가 되는데 이 문자 목록은 배열 초기자(array initializer) [...str]로 전달된다.
메서드 Array.from은 이터러블 객체인 문자열을 배열로 바꿔주기 때문에 Array.from을 사용해도 동일한 작업을 할 수 있다.
let str = "Hello";
// Array.from은 이터러블을 배열로 바꿔준다.
alert(Array.from(str)); // H,e,l,l,o
[...str]과 동일한 결과가 출력되는 것을 확인할 수 있다.
그런데 Array.from(obj)와 [...obj]는 다음과 같은 미묘한 차이가 있다.
참조에 의한 객체 복사 챕터에서 Object.assign()을 사용해 객체를 복사했다.
Object.assign()말고도 스프레드 문법을 사용하면 배열과 객체를 복사할 수 있다.
let arr = [1,2,3];
let arrCopy = [...arr] // 배열을 펼쳐서 각 요소를 분리 후, 매개변수 목록으로 만든 다음
// 매개변수 목록을 새로운 배열에 할당함
// 배열 복사본의 요소가 기존 배열 요소와 같을까?
alert(JSON.stringify(arr) === JSON.stringify(arrCopy)); // true
// 두 배열은 같을까?
alert(arr = arrCopy); // false(참조가 다름)
// 참조가 다르므로 기존 배열을 수정해도 복사본은 영향을 받지 않는다.
arr.push(4);
alert(arr); // 1,2,3,4
alert(arrCopy); // 1,2,3
이번엔 객체를 복사하는 예시를 살펴보자
let obj = { a: 1, b: 2, c: 3 };
let objCopy = {...obj}; // 객체를 펼쳐서 각 요소를 분리한 후, 매개변수 목록으로 만든 다음에
// 매개변수 목록을 새로운 객체에 할당함
// 객체 복사본의 프로퍼티들이 기존 객체의 프로퍼티들과 같을까?
alert(JSON.stringify(obj) === JSON.stringify(objCopy)); // true
// 두 객체는 같을까?
alert(obj === objCopy); // false (참조가 다름)
// 참조가 다르므로 기존 객체를 수정해도 복사본은 영향을 받지 않습니다.
obj.d = 4;
alert(JSON.stringify(obj)); // {"a": 1, "b": 2, "c": 3, "d": 4}
alert(JSON.stringify(objCopy); // {"a": 1, "b": 2, "c": 3}
이렇게 전개 구문을 사용하면
let objCopy = Object.assgin({},obj);
let arrCopy = Objet.assign([],arr)
보다 더 짧은 코드로 배열이나 객체를 복사할 수 있어서 사람들은 이 방법을 더 선호한다.
"..."은 나머지 매개변수나 스프레드 문법으로 사용할 수 있다.
나마지 매개변수와 전개 구문은 아래의 방법으로 구분할 수 있다.
사용 패턴:
둘을 함께 사용하면 매개변수 목록과 배열 간 전환을 쉽게 할 수 있다.
조금 오래된 방법이긴 하지만 arguments라는 반복가능한 유사 배열 객체를 사용해도 인수 모두를 사용할 수 있다.
자바스크립트는 함수 지향 언어이다.
이런 특징은 개발자에게 많은 자유를 준다.
함수를 동적으로 생성할 수 있고, 생성한 함수를 다른 함수에 인수로 넘길 수 있으며,
생성된 곳이 아닌 곳에서 함수를 호출할 수도 있기 때문이다.
함수 내부에서 함수 외부에 있는 변수에 접근할 수 있다
그런데 함수가 생성된 이후에 외부 변수가 변경되면 어떤 일이 발생할까?
함수는 호출되는 곳을 기준으로 외부 변수에 접근할까?
코드블록 {...}안에서 선언한 변수는 블록 안에서만 사용할 수 있다.
이런 블록의 특징은 특정 작업을 수행하는 코드를 한데 묶어두는 용도로 활용할 수 있다.
블록 안엔 작업 수행에만 필요한 변수가 들어간다.
{
let message = "안녕";
alert(message);
};
{
let message = "잘가";
alert(message);
}
블록이 없으면 에러가 발생하낟.
이미 선언된 변수와 동일한 이름을 가진 변수를 별도의 블록 없이 let으로 선언하면 에러가 발생한다.
if, for, while등에서도 마찬가지로 {...}안에서 선언한 변수는 오직 블록 안에서만 접근 가능하다.
함수 내부에서 선언한 함수는 중첩(nested)함수라고 부른다.
자바스크립트에선 손쉽게 중첩 함수를 만들 수 있다.
중첩 함수는 아래와 같이 코드를 정돈하는데 사용할 수 있다.
function sayHiBye(firstName, lastName) {
// 헬퍼(helper) 중첩 함수
function getFullName() {
return firstName + " " + lastName;
}
alert("Hello, " + getFullName() );
alert("Bye, " + getFullName() );
}
위 예시에서 외부 변수에 접근해 이름 전체를 반환해주는 중첩함수 getFullName()은 편의상 만든 함수이다
이렇게 자바스크립트에서는 중첩 함수가 흔히 사용된다.
중첩 함수는 새로운 객체의 프로퍼티 형태나 중첩 함수 그 자체로 반환될 수 있다는 점에서 흥미롭다.
이렇게 반환된 중첩함수는 어디서든 호출해 사용할 수 있다.
물론 이때도 외부 변수에 접근할 수 있다는 사실은 변함없다.
아래 함수 makeCounter는 호출될 떄마다 다음 숫자를 반환해주는 카운터 함수를 만든다.
```jsx
function makeCounter() {
let count = 0;
return function () {
return count ++;
}
}
let counter = makeCounter();
alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2
예시의 makeCounter는 아주 단순하지만 여기에 약간의 변형을 가하면 난수 생성기 같은 실용성 있는 함수를 만들 수 있다.
그런데 makeCounter를 살펴보다 보면 counter를 여러 개 만들었을 때, 이 함수들은 서로 독립적일까?
함수와 중첩 함수 내 count 변수엔 어떤 값이 할당될까 라는 의문이 들 수 있다.
자바스크립트에선 실행 중인 함수, 코드 블록 {...}, 스크립트 전체는 렉시컬 환경이라 불리는
내부 숨김 연관 객체를 갖는다.
렉시컬 환경 객체는 두 부분으로 구성된다.
1. 환경 레코드 : 모든 지역 변수를 프로퍼티로 저장하고 있는 객체이다. this 값과 같은 기타 정보도 여기에 저장된다.
2. 외부 렉시컬 환경에 대한 참조: 외부 코드와 연관됨