만약 프로그래밍 언어를 사용하여 코드를 작성할 수 있다면, 반복문(statement)을 사용해야 합니다. 하지만, 반복문에 대해 얼마나 알고 계신가요?
오늘은 반복문에 대해 이야기해보겠습니다.
반복문은 특정 조건이 참으로 평가되는 한 코드 블록을 반복해서 실행할 수 있게 하는 제어 구조입니다.
기본 자바스크립트에는 세 가지 반복문이 있습니다.
for, while, do ... while
물론 자바스크립트에는 반복문을 대체할 수 있는 몇 가지 함수도 있습니다.
for ... in, for ... of
배열을 순회할 수 있는 고차 함수도 있습니다.
forEach
이것은 일반적으로 사용되는 for 반복문 형태입니다.
for (초기화; 조건; 증가) {
// 실행될 코드
}
while 반복문은 지정된 조건이 참으로 평가되는 동안 코드 블록을 실행합니다. 반복 횟수가 불확실할 때 주로 사용됩니다.
do..while 반복문은 조건이 테스트되기 전에 코드 블록을 보장합니다. 따라서 코드 블록이 최소한 한 번은 실행됩니다.
이들은 반복문 내에서 사용되는 제어문입니다.
// break 사용 예
for (let i = 0; i < 5; i++) {
if (i === 3) {
break;
}
console.log(i); // 0, 1, 2 출력
}
// continue 사용 예
for (let i = 0; i < 5; i++) {
if (i === 3) {
continue;
}
console.log(i); // 0, 1, 2, 4 출력
}
이제 반복문을 대체할 수 있는 문장에 대해 알아보겠습니다.
자바스크립트의 for...in과 for...of 반복문은 다양한 종류의 컬렉션을 순회하는 데 사용되지만, 목적과 동작이 다릅니다.
이터레이션 프로토콜?
- iterable 프로토콜: Symbol.iterator 키를 가진 메서드를 구현한 객체는 iterable로 간주됩니다.
iterable 프로토콜을 준수하는 객체는 for...of 반복문, 스프레드 구문에서 사용할 수 있습니다.
예) 배열, 문자열, 맵, 세트. 객체는 아님!- iterator 프로토콜: next() 메서드를 가지고 있으며, 호출할 때마다 { value, done } 객체를 반환합니다. value: 현재 반복의 값 done: 반복이 끝났는지 여부 (true/false)
for...in 반복문은 객체의 열거 가능한 속성을 순회합니다.
다만 for...in 반복문은 [[Enumerable]] 속성이 true로 설정된 프로토타입 체인의 속성을 열거합니다. [[Enumerable]] 속성은 프로퍼티의 열거 가능여부를 나타낸다.
const person = { fname: "John", lname: "Doe", age: 25 };
// in 연산자는 객체가 상속받은 모든 프로토타입의 프로퍼티를 확인한다.
console.log('toString' in person) // true
// 객체가 상속받은 모든 프로토타입의 프로퍼티를 열거하나,
// toString 과 같은 Object.prototype의 프로퍼티가 열거되지 않는다.
for (let key in person) {
console.log(key + ": " + person[key]); // "fname: John", "lname: Doe", "age: 25" 출력
}
키가 Symbol인 프로퍼티는 열거하지 않는다.
for...of 반복문은 이터러블을 순회하면서 이터러블의 요소를 변수에 할당합니다. 배열, 문자열, 맵, 세트 등의 반복 가능한 객체의 값을 순회합니다.
const numbers = [1, 2, 3, 4, 5];
for (let number of numbers) {
console.log(number); // 1, 2, 3, 4, 5 출력
}
내부적으로 이터레이터의 next 메서드를 호출하여 이터러블을 순회하며 next 메서드가 반환한 객체의 value 프로퍼티 값을 for ...of 변수에 할당합니다. 그리고 그 result 객체의 done 프로퍼티 값이 false 이면 순회를 계속하고 true 이면 순회를 중단합니다.
// 이터러블
const iterable = [1,2,3]
// 이터러블의 Symbol.iterator 메서드를 호출하여 이터레이터 생성
const iterator = iterable[Symbol.iterator]();
for(;;){
// 이터레이터의 next 메서드를 호출하여 이터러블을 순회하는데,
// next 메서드는 이터레이터 result 객체를 반환한다.
const res = iterator.next();
// next 메서드가 반환한 이터레이터 result 객체의 done 프로퍼티 값이 true이면 이터러블의 순회를 중단한다.
if(res.done) break;
// 이터레이터 result 객체의 value 프로퍼티 값을 item 변수에 할당한다.
consnt item = res.value
console.log(item)
}
일반 객체는 이터레이션 프로토콜을 준수(Symbol.iterator 메소드를 소유)하지 않기 때문에 이터러블이 아니다. 따라서 일반 객체는 for…of 문에서 순회할 수 없으며 Spread 문법의 대상으로 사용할 수도 없다.
Spread 문법으로 사용 가능한 것은 es2018에 도입되었으나, 이는 이터러블이 아니고 배열과도 다르게 동작한다.
const obj = { a: 1, b: 2 };
// 일반 객체는 Symbol.iterator 메소드를 소유하지 않는다.
// 따라서 일반 객체는 이터러블 프로토콜을 준수한 이터러블이 아니다.
console.log(Symbol.iterator in obj); // false
// 이터러블이 아닌 일반 객체는 for...of 문에서 순회할 수 없다.
// TypeError: obj is not iterable
for (const p of obj) {
console.log(p);
}
유사배열객체
유사배열객체(array-like object)는 배열과 유사한 특성을 가지지만, 실제 배열은 아닌 객체입니다.
유사배열객체는 배열처럼 인덱스를 통해 요소에 접근할 수 있으며 length 속성을 가지기 때문에 for문 순회가 가능하나 배열의 메서드(예: push, pop, forEach 등)를 직접적으로 사용하지 못합니다.
const arrayLike = {
0: 1,
1: 2,
3: 3,
length: 3
}
for (let i = 0; i < arrayLike.length; i++){
console.log(arrayLike[i])
}
유사배열객체는 이터러블이 아닌 일반 객체이기 때문에, Symbol.iterator가 없어 for of 문으로 순회할 수 없다.
다만, arguments, NodeList, HTMLCollection은 유사 배열 객체이면서 이터러블이기 때문에 for of 문으로 순회가 가능하다.
다만, Array.from을 통해 유사배열 객체 혹은 이터러블을 인수로 전달받아 배열로 변환하여 반환할 수 있다.
function example() {
const argsArray = Array.from(arguments);
console.log(argsArray); // [1, 2, 3]
}
example(1, 2, 3);
사실, 배열에 대해서는 for ... in 반복문을 사용하는 것이 권장되지 않습니다. 대신, for ... of 또는 forEach 반복문을 사용하는 것이 좋습니다.
그 이유는 배열도 객체 타입이기 때문입니다. 그래서 배열도 속성을 가질 수 있습니다. 하지만 일반적으로 배열은 순서가 있는 데이터이기 때문에 인덱스를 통해 접근할 수 있습니다.
const arr = [1, 2, 3];
arr.customProperty = "나는 커스텀 속성입니다";
for (let key in arr) {
console.log(key); // "0", "1", "2", "customProperty" 출력
}
console.log(arr.length); // 3
for (let value of arr) {
console.log(value); // "1", "2", "3" 출력
}
arr.forEach(value => {
console.log(value); // "1", "2", "3" 출력
});
이터레이션 프로토콜 왜 필요한가?
이터러블은 데이터 공급자(Data provider)의 역할을 한다.
만약 이처럼 다양한 데이터 소스가 각자의 순회 방식을 갖는다면 데이터 소비자는 다양한 데이터 소스의 순회 방식을 모두 지원해야 하는데, 이는 효율적이지 않다.
하지만 다양한 데이터 소스가 이터레이션 프로토콜을 준수하도록 규정하면 데이터 소비자는 이터레이션 프로토콜만을 지원하도록 구현하면 된다.
즉, 이터레이션 프로토콜은 다양한 데이터 소스가 하나의 순회 방식을 갖도록 규정하여 데이터 소비자가 효율적으로 다양한 데이터 소스를 사용할 수 있도록 데이터 소비자와 데이터 소스를 연결하는 인터페이스의 역할을 한다.
이제 배열을 순회할 수 있는 다양한 고차함수에 대해 알아보자.
고차 함수는 함수를 인수로 전달받거나 함수를 반환하는 함수를 말한다.
우선 배열은 무엇일까?
배열
배열은 여러 개의 값을 순차적으로 나열한 자료구조다. 자바스크립트에서 값으로 인정하는 원시값을 포함한 모든 것은 배열의 요소가 될 수 있다. (그러나 같은 타입의 요소를 연속적으로 위치시키는 것이 좋다)
배열의 요소들은 자신의 위치를 나타내는 0이상의 정수인 인덱스를 갖는다.
자바스크립트에 배열이라는 타입은 존재하지 않으며, 배열은 객체 타입이다.
다만, 일반 객체와 달리 배열은 값의 순서와 length 프로퍼티를 가지고 있다. 이는 반복문을 통해 순차적으로 값에 접근하기 적합한 자료구조이다.
forEach 메서드는 for문을 대체할 수 있는 고차 함수다.
forEach 메서드의 콜백함수는 forEach 메서드를 호출한 배열의 요소값과 인덱스, 배열(this)를 순차적으로 전달 받을 수 있다.
반환값은 언제나 undefined 이다.
const numbers = [1, 3, 5, 7, 9];
let total = 0;
// forEach 메소드는 인수로 전달한 보조 함수를 호출하면서
// 3개(배열 요소의 값, 요소 인덱스, this)의 인수를 전달한다.
// 배열의 모든 요소를 순회하며 합산한다.
numbers.forEach(function (item, index, self) {
console.log(`this: ${JSON.stringfy(arr)}`); // this: [1, 3, 5, 7, 9]
total += item;
});
console.log(total); // 25
forEach 메소드는 원본 배열(this)을 변경하지 않는다. 하지만 콜백 함수는 원본 배열(this)을 변경할 수는 있다. 원본 배열을 직접 변경하려면 콜백 함수의 3번째 인자(this)를 사용한다.
const numbers = [1, 2, 3, 4];
numbers.forEach(function (item, index, self) {
self[index] = Math.pow(item, 2);
});
console.log(numbers); // [ 1, 4, 9, 16 ]
forEach 메서드의 콜백함수는 일반 함수로 호출되므로, 콜백함수 내부 this는 undefined 이다. (클래스 내부의 모든 코드는 strict mode가 적용되기 때문이다.)
forEach 메소드에 두번째 인자로 this를 전달할 수 있다.
function Square() {
this.array = [];
}
Square.prototype.multiply = function (arr) {
arr.forEach(function (item) {
this.array.push(item * item);
}, this);
};
const square = new Square();
square.multiply([1, 2, 3]);
console.log(square.array); // [ 1, 4, 9 ]
ES6의 Arrow function를 사용하면 this를 생략하여도 동일한 동작을 한다. 화살표 함수는 함수 자체의 this 바인딩을 갖지 않기 때문에, 화살표 함수 내부에서 this를 참조하면 상위 스코프의 this를 그대로 참조한다.
Square.prototype.multiply = function (arr) {
arr.forEach(item => this.array.push(item * item));
};
forEach 메서드는 내부적으로 반복문을 통해 배열을 순회한다. 그러나 for문과는 다르게, break나 continue 문을 사용할 수 없다. (중간에 순회를 중단할 수 없다)
// 배열에 대해 forEach 메서드를 직접 구현한 예시
Array.prototype.myForEach = function(callback, thisArg) {
// this는 호출한 배열을 가리킴
const array = this;
// 배열의 길이를 저장
const length = array.length;
// for 문을 사용하여 배열의 각 요소를 순회
for (let index = 0; index < length; index++) {
// 현재 요소를 가져옴
const currentValue = array[index];
// 콜백 함수를 호출, thisArg를 전달하여 this 컨텍스트를 설정
callback.call(thisArg, currentValue, index, array);
}
};
// 예제 배열
const array = [1, 2, 3, 4, 5];
// myForEach 메서드 사용 예시
array.myForEach((value, index) => {
console.log(`Index: ${index}, Value: ${value}`);
});
존재하지 않는 요소는 순회 대상에서 제외된다. 이는 map, filter, reduce 등에서도 마찬가지이다.
const sparseArray = [1, , 3]; // 두 번째 요소는 정의되지 않음
for (let i = 0; i < sparseArray.length; i++) {
console.log(`Index: ${i}, Value: ${sparseArray[i]}`);
}
// 출력 결과:
// Index: 0, Value: 1
// Index: 1, Value: undefined
// Index: 2, Value: 3
sparseArray.forEach((value, index) => {
console.log(`Index: ${index}, Value: ${value}`);
});
// 출력 결과:
// Index: 0, Value: 1
// Index: 2, Value: 3
for 문에 비해 성능이 좋지는 않으나, 가독성은 더 좋다. 따라서 요소가 대단히 많은 배열을 순회하거나 높은 성능이 필요한 경우가 아니라면 forEach를 권장한다.
map메서드는 자신을 호출한 배열의 모든 요소를 순회하면서 인수로 전달받은 콜백함수를 반복 호출한다. 콜백함수의 반환값들로 구성된 새로운 배열을 반환한다. 이때 원본 배열을 변경하지 않는다.
(forEach의 반환값은 Undefined 라는 차이가 있다.)
const numbers = [1, 4, 9];
// 배열을 순회하며 각 요소에 대하여 인자로 주어진 콜백함수를 실행
const roots = numbers.map(function (item) {
// 반환값이 새로운 배열의 요소가 된다. 반환값이 없으면 새로운 배열은 비어 있다.
return Math.sqrt(item);
});
// 위 코드의 축약표현은 아래와 같다.
// const roots = numbers.map(Math.sqrt);
// map 메소드는 새로운 배열을 반환한다
console.log(roots); // [ 1, 2, 3 ]
// map 메소드는 원본 배열은 변경하지 않는다
console.log(numbers); // [ 1, 4, 9 ]
마찬가지로 map의 두 번째 인자에 this를 전달할 수 있다.
function Prefixer(prefix) {
this.prefix = prefix;
}
Prefixer.prototype.prefixArray = function (arr) {
// 콜백함수의 인자로 배열 요소의 값, 요소 인덱스, map 메소드를 호출한 배열, 즉 this를 전달할 수 있다.
return arr.map(function (x) {
// x는 배열 요소의 값이다.
return this.prefix + x; // 2번째 인자 this를 전달하지 않으면 this는 undefined(비엄격 모드에서는 window)
}, this);
};
const pre = new Prefixer('-webkit-');
const preArr = pre.prefixArray(['linear-gradient', 'border-radius']);
console.log(preArr);
// [ '-webkit-linear-gradient', '-webkit-border-radius' ]
화살표 함수는 자신만의 this를 가지지 않기 때문에, this를 전달하지 않아도 상위 스코프의 this를 사용할 수 있다.
const array = [1, 2, 3];
const context = { multiplier: 2 };
array.map(value => {
console.log(this); // 상위 스코프의 this 값을 출력
return value * this.multiplier;
}, context);
filter는 forEach, map과 마찬가지로 자신을 호출한 배열의 모든 요소를 순회하면서, 인수로 전달받은 콜백 함수를 반복 호출한다. 그러나 반환값이 true인 요소로만 구성된 새로운 배열을 반환한다는 차이점이 있다.
filter도 3개의 인수를 받는다. 배열의 요소값, 인덱스, this를 순차적으로 전달한다.
const result = [1, 2, 3, 4, 5].filter(function (item, index, self) {
console.log(`[${index}] = ${item}`);
return item % 2; // 홀수만을 필터링한다 (1은 true로 평가된다)
});
console.log(result); // [ 1, 3, 5 ]
두 번째 인자로 this를 전달할 수 있으며, 더 나은 방법 역시 화살표 함수이다.
동작을 뜯어보면 다음과 같다.
Array.prototype.myFilter = function (predicate) {
// 첫번재 매개변수에 함수가 전달되었는지 확인
if (!predicate || {}.toString.call(predicate) !== '[object Function]') {
throw new TypeError(predicate + ' is not a function.');
}
const result = [];
for (let i = 0, len = this.length; i < len; i++) {
/**
* 배열 요소의 값, 요소 인덱스, 메소드를 호출한 배열, 즉 this를 매개변수를 통해 predicate에 전달하고
* predicate를 호출하여 그 결과가 참인 요소만을 반환용 배열에 푸시하여 반환한다.
*/
if (predicate(this[i], i, this)) result.push(this[i]);
}
return result;
};
const result = [1, 2, 3, 4, 5].myFilter(function (item, index, self) {
console.log(`[${index}]: ${item} of [${self}]`);
return item % 2; // 홀수만을 필터링한다 (1은 true로 평가된다)
});
console.log(result); // [ 1, 3, 5 ]
reduce 메서드는 자신을 호출한 배열의 모든 요소를 순회하며 인수로 전달받은 콜백 함수를 반복 호출하며, 그 반환값을 다음 순회 시에 콜백 함수의 첫 번째 인수로 전달하면서 콜백 함수를 호출하여, 하나의 결과값을 만들어 반환한다. 이때 원본 배열은 변경되지 않는다.
reduce메서드는 첫 번째 인수로는 콜백 함수, 두 번째 인수로 초기값을 전달받는다.
(reduce의 초기값은 생략 가능하나, 전달하는 것이 안전하다. 빈 배열일 경우 초기값이 없으면 에러가 나기 때문이다.)
콜백함수는 4개의 인수를 전달받는데, 초기값 또는 콜백 함수의 이전 반환값, reduce 메서드를 호츨한 배열의 요소값과 인덱스, this가 전달된다.
/*
accumulator : 이전 콜백의 반환값
currentValue : 배열 요소의 값
currentIndex : 인덱스
array : 메소드를 호출한 배열, 즉 this
**/
const array = [1, 2, 3, 4];
const sum = array.reduce((accumulator, currentValue) => {
return accumulator + currentValue;
}, 0);
console.log(sum); // 10
객체의 특정 프로퍼티 값을 합산하는 경우에는 에러를 방지하기 위해 반드시 초기값을 전달해야 한다.
const products = [
{ id: 1, price: 100 },
{ id: 2, price: 200 },
{ id: 3, price: 300 }
];
// 프로퍼티 값을 합산
const priceSum = products.reduce(function (pre, cur) {
console.log(pre.price, cur.price);
// 숫자값이 두번째 콜백 함수 호출의 인수로 전달된다. 이때 pre.price는 undefined이다.
return pre.price + cur.price;
});
console.log(priceSum); // NaN
// 프로퍼티 값을 합산
const priceSum2 = products.reduce(function (pre, cur) {
console.log(pre, cur.price);
return pre + cur.price;
}, 0);
console.log(priceSum2); // 600
배열의 모든 요소가 주어진 조건을 만족하는지 확인합니다.
조건을 모두 만족하면 true, 그렇지 않으면 false를 반환합니다.
const array = [1, 2, 3, 4];
const allEven = array.every(element => element % 2 === 0);
console.log(allEven); // false
배열의 어떤 요소라도 주어진 조건을 만족하는지 확인합니다.
하나라도 만족하면 true, 모두 만족하지 않으면 false를 반환합니다.
const array = [1, 2, 3, 4];
const someEven = array.some(element => element % 2 === 0);
console.log(someEven); // true
주어진 조건을 만족하는 첫 번째 요소를 반환합니다.
만족하는 요소가 없으면 undefined를 반환합니다.
const array = [1, 2, 3, 4];
const found = array.find(element => element > 2);
console.log(found); // 3
주어진 조건을 만족하는 첫 번째 요소의 인덱스를 반환합니다.
만족하는 요소가 없으면 -1을 반환합니다.
const array = [1, 2, 3, 4];
const index = array.findIndex(element => element > 2);
console.log(index); // 2
각 요소에 대해 매핑 함수(map function)를 적용한 후, 결과를 새로운 배열로 평탄화합니다(flatten).
const array = [1, 2, 3];
const flatMappedArray = array.flatMap(element => [element, element * 2]);
console.log(flatMappedArray); // [1, 2, 2, 4, 3, 6]