December 16th 2018
최근에 자바스크립트의 async 라이브러리 중 일부 메소드를 구현해 보는 연습과 함께, 함수형 라이브러리인 ramda.js의 일부 메소드를 구현해 보는 연습을 했습니다. 아직 함수형 프로그래밍에 대한 개념이 잘 잡히질 않았고 익숙해 지려면 많은 시간과 노력이 필요하겠지만, 함수형 프로그래밍이 대략 어떠한 식으로 작동하는지 감을 잡기에 좋은 시간이었습니다. 아래는 제가 연습했던 ramda.js의 일부 메소드에 대한 복습용으로 정리해 보았습니다.
Contents
add 함수는 2개의 인자를 받으며, 인자로 받은 두 수를 더해주는 간단한 함수입니다. 만약 처음에 인자를 한 개만 받으면, 받은 인자가 바인딩 된 함수를 반환합니다. 그리고 추가로 들어온 인자는 무시합니다.
function add(num1, num2) {
if (arguments.length >= 2) {
return Number(num1) + Number(num2);
} else if (arguments.length === 1) {
return add.bind(null, num1);
} else {
return add;
}
}
subtract는 빼기 함수입니다. 두 개의 인자를 받으면 첫 번째 인자에서 두 번째 인자를 빼주는 기능을 합니다. 위의 add와 마찬가지로 인자를 한 개만 받으면, 먼저 받은 인자가 바인딩 된 함수를 반환합니다. 2개 이상 들어온 인자는 무시합니다.
function subtract(num1, num2) {
if (arguments.length >= 2) {
return Number(num1) - Number(num2);
} else if (arguments.length === 1) {
return subtract.bind(null, num1);
} else {
return subtract;
}
}
본래 ramda.js 라이브러리의 subtract는 함수를 인자로 받지 않습니다. 대신 R.라는 placeholder가 있습니다. R.를 함수의 원하는 위치의 인자로 넣어주면, 향후 그 자리에 인자를 추가할 수 있습니다. 저는 이 Placeholder 대신 함수를 인자로 전달해 이후에 함수가 있는 자리에 인자를 추가할 수 있도록 subtract를 수정해 보았습니다.
function subtract(num1, num2) {
const args = Array.prototype.slice.call(arguments, 0, 2);
const hasFunction = args.some(el => typeof el === 'function');
if (hasFunction) {
const callback = args.reduce((found, item, idx) => {
if (typeof item === 'function') {
if (found) {
found[idx] = item;
found.length += 1;
} else {
found = {};
found[idx] = item;
found.length = 1;
}
}
return found;
}, false);
if (callback.length === 2) {
return function(number1, number2) {
return callback[0](Number(number1)) - callback[1](Number(number2));
};
} else {
if (callback[0]) {
return function(num) {
return callback[0](Number(num)) - Number(num2);
};
} else {
return function(num) {
return Number(num1) - callback[1](Number(num));
};
}
}
} else {
if (arguments.length >= 2) {
return Number(num1) - Number(num2);
} else if (arguments.length === 1) {
return subtract.bind(null, num1);
} else {
return subtract;
}
}
}
multiply와 divide 함수도 만들어 보았지만, 기본적인 작동 방식이 위의 add, subtract와 같기때문에, divide만 아래와 같이 적어봅니다.
function divide(num1, num2) {
const args = Array.prototype.slice.call(arguments, 0, 2);
const hasFunction = args.some(el => typeof el === 'function');
if (hasFunction) {
const callback = args.reduce((found, item, idx) => {
if (typeof item === 'function') {
if (found) {
found[idx] = item;
found.length += 1;
} else {
found = {};
found[idx] = item;
found.length = 1;
}
}
return found;
}, false);
if (callback.length === 2) {
return function(number1, number2) {
return callback[0](Number(number1)) / callback[1](Number(number2));
};
} else if (callback[0]) {
return function(num) {
return callback[0](Number(num)) / Number(num2);
};
} else if (callback[1]) {
return function(num) {
return Number(num1) / callback[1](Number(num));
};
}
} else {
args = args.map(num => Number(num));
if (args.length === 2) {
return args[0] / args[1];
} else if (args.length === 1) {
return function(num) {
return args[0] / Number(num);
};
} else {
return divide;
}
}
}
R.is는 인자로 Contstructor와 value를 받습니다. value가 인자로 받은 constructor의 instance인 경우 true를, 그렇지 않으면 false를 반환합니다.
is 함수에 value 인자로 원시값을 받아도 그에 상응하는 Constructor가 맞다면 true를 반환해야 합니다. 주의해야 할 점은 null과 undefined는 변수에 담아도 constructor 속성이 없기 때문에 .constructor로 접근을 하면 에러가 발생합니다.
function is(cstr, instance) {
if (arguments.length === 1) {
return function(value) {
return value !== undefined && value !== null && (value instanceof cstr || value.constructor === cstr);
};
}
return instance !== undefined && instance !== null && (instance instanceof cstr || instance.constructor === cstr);
}
all 메소드는 test 함수와 배열을 인자로 받습니다. 각 배열의 모든 요소가 test 함수에 통과하면 true를 그렇지 않으면 false를 반환합니다.
function all(fn, list) {
return arguments.length === 1 ? validateList : validateList(list);
function validateList(array) {
for (let i = 0; i < list.length; i++) {
if (!testResult(list[i])) {
return false;
}
}
return testResult;
}
}
기본적으로 자바스크립트의 map 메소드와 기능은 같습니다. 인자로 전달 받은 callback에 각 collection의 요소를 인자로 전달해 실행한 결과값을 모은 새로운 collection을 반환합니다.
function map(fn, list) {
const args = [...arguments].slice(0, 2);
const hasFunction = args.some(el => typeof el === 'function');
if (hasFunction) {
const callback = args.reduce((found, item, idx) => {
if (typeof item === 'function') {
if (found) {
found[idx] = item;
found.length += 1;
} else {
found = {};
found[idx] = item;
found.length = 1;
}
}
return found;
}, false);
if (callback.length === 1) {
return map.bind(null, callback[0]);
} else if (callback.length === 2) {
return function(value) {
return callback[0](callback[1](value));
};
}
}
if (Array.isArray(list)) {
const newArray = [];
for (let i = 0; i < list.length; i++) {
newArray.push(fn(list[i]));
}
return newArray;
} else {
const newObject = {};
for (let key in list) {
if (list.hasOwnProperty(list)) {
newObject[key] = fn(list[key]);
}
}
return newObject;
}
}
chain은 인자로 callback과 배열을 전달 받고, 배열의 요소에 callback을 실행한 각 결과값을 연결한 새로운 배열을 반환해줍니다. 다른 몇몇 라이브러리에서 chain은 flatMap으로 알려져 있습니다.
만약 두 번째 인자가 함수로 들어오면, 인자로 받은 함수들이 연결된 함수를 반환해야 합니다. chain(f, g)(x)는 f(g(x), x)와 같은 형태로 진행되어야 합니다.
curried function은 두 가지 특징이 있습니다. 첫째로, 함수의 arguments가 한번에 전달될 필요는 없습니다. 만약 f가 ternary function이고 g가 R.curry(f)라며 아래의 실행은 동일한 결과를 가져와야 합니다.
g(1)(2)(3)
g(1)(2, 3)
g(1, 2)(3)
g(1, 2, 3)
function curry(func) {
Object.defineProperty(curriledFunction, 'length', { value: func.length });
function curriedFunction(...args) {
if (func.length === arguments.length) {
return func.call(null, ...arguments);
} else return curriedFunction.bind(null, ...args);
}
}
Transpose는 이름대로 인자로 받은 배열의 구조를 변경한 새로운 배열을 반환해주는 함수입니다. 인자로 받은 배열의 길이가 n이고 배열 요소인 내부 배열의 길이가 x라면, 새로운 배열의 길이는 x가 되고, 새로운 배열의 내부 배열의 길이는 n이 됩니다. 아래의 예시를 참고해주세요.
// Example
R.transpose([[1, 'a'], [2, 'b'], [3, 'c']]) //=> [[1, 2, 3], ['a', 'b', 'c']]
R.transpose([[1, 2, 3], ['a', 'b', 'c']]) //=> [[1, 'a'], [2, 'b'], [3, 'c']]
// If some of the rows are shorter than the following rows, their elements are skipped:
R.transpose([[10, 11], [20], [], [30, 31, 32]]) //=> [[10, 20, 30], [11, 31], [32]]
R.transpose([[a], [b], [c]]) = [a, b, c]
R.transpose([[a, b], [c, d]]) = [[a, c], [b, d]]
R.transpose([[a, b], [c]]) = [[a, c], [b]]
2D 배열의 행과 열을 바꾼다고 생각하고 코드를 작성하면 이해하기가 좀 더 쉬운것 같습니다.
function transpose(lsit) {
const transposedArray = [];
let outerLength;
if (list.length === 0) {
return transposedArray;
}
outerLength = list.reduce(function(prevArrayLength, curArray) {
return prevArrayLength <= curArray.length ? curArray.length : prevArrayLength;
}, list[0].length);
for (let i = 0; i < outerLength; i += 1) {
const innerArray = [];
for (let j = 0; j < list.length; j += 1) {
if (list[j].length >= i) {
innerArray.push(list[j][i]);
}
}
}
return transposedArray;
}
ramda.js의 소스코드를 확인해 보니 while문 두 개로 transpose를 구현했습니다. 위의 제 코드는 인자로 받은 list의 내부 배열의 길이를 확인하는 작업(reduce)를 실행했는데, 아래의 코드는 이러한 작업이 생략되어 있어 연산이 더 적게 짜여진 것 같습니다.
function transpose(outerList) {
const result = [];
for (let i = 0; i < outerList.length; i += 1) {
const innerList = outerList[i];
for (let j = 0; j < innerList.length; j += 1) {
if (result[j] === undefined) {
result[j] = [];
}
result[j].push(innerList[j]);
}
}
return result;
}