배열과 객체 다루기

2017년 03월 17일


객체 다루기 2

객체는 다양한 키를 갖고 중첩되어 표현될 수 있습니다. 기존의 방식에서는 이런 복잡한 데이터를 직접 변경해서 다뤘습니다.

하지만 최근에는 redux처럼 직접 값을 변경하지 않고 변경된 값을 요구하는 상황이 많습니다. 이러한 상황에선 기존의 방식을 적용할 수 없습니다.

기존의 방식을 사용하면 객체를 해체하고 변경된 부분만 새로 만들어 조합하거나, 새롭게 복사한 뒤 변경하는 방식으로 접근해야 했습니다. 별 기능 없는 코드도 장황하게 만들어버리는 문제점이 있습니다.

이러한 문제에 특화된 라이브러리도 있습니다. 변경된 값을 얻기 쉬운 새로운 타입을 제공합니다.

이에 비해 Ramda는 다른 방식으로 접근합니다. 기존의 타입을 사용하고 확장 가능한 함수들을 제공합니다.

merge

merge는 두 객체를 하나의 객체로 만드는 함수입니다. 중복된 키가 있다면 두 번째 인자의 값을 선택합니다.

import * as R from 'ramda';

const x = { a : 20, b: 30 };
const y = { b : 40, c: 60 };

R.merge(x, y); // { a: 20, b: 40, c: 60 }

이 함수는 assoc 대신 사용할 수 있습니다. 특히 변수 이름과 필드 이름이 같을 때 유용합니다.

import * as R from 'ramda';

const object = { a : 20, b: 30 };
const c = 40;

R.assoc('c', c, object); // { a : 20, b: 30, c: 40 }
R.merge(object, { c });  // { a : 20, b: 30, c: 40 }

map

Ramda에서 제공하는 map은 다형성을 갖고 있기 때문에 객체를 인자로 사용할 수 있습니다.

배열에 대한 map이 index에 접근할 수 없었던 것 처럼, key에는 접근할 수 없고 값에만 접근합니다.

반환되는 값이 배열이 아닌 객체라는 것에 유의합니다.

import * as R from 'ramda';

const x = { a: 0, b: 1, c: 2, d: 3 };
const f = R.map(R.inc);
f(x); // { a: 1, b: 2, c: 3, d: 4 }

R.map은 배열과 객체 말고도 다양한 타입에 적용할 수 있습니다. 콜백에서 index에 접근할 수 없던 것이 이러한 다형성을 위한 것입니다.

filter

map과 마찬가지로 filter도 다형성이 있습니다. 객체에도 filter를 적용할 수 있습니다

import * as R from 'ramda';

const object = { a: 0, b: 1, c: 2, d: 3 };
const isOdd  = (v) => v % 2 === 1;

const oddFilter = R.filter(isOdd);
oddFilter(object); // { b: 1, d: 3 };

where

where은 객체의 필드에 조건을 검사합니다. 검사하고 싶은 필드와 적용할 predicate를 객체로 표현하고 where 함수를 통해 적용합니다.

import * as R from 'ramda';

const a = { x: 15, y: 497, z: 'A' };
const b = { x: 25, y: 623, z: 'BB' };
const c = { x: 35, y: 157, z: 'CCC' };
const d = { x: 45, y: 273, z: 'DDDD' };

const pred = R.where({
  x: (value) => value > 17,
  z: (value) => value.length > 2
});

pred(a); // false
pred(b); // false
pred(c); // true
pred(d); // true

고차함수를 응용해서 where를 커링해서 predicate를 얻은 뒤 filter의 인자로 사용할 수 있습니다.

다음은 데이터에서 30세가 넘는 여성들의 index, age, name을 추출하는 예시입니다.

import * as R from 'ramda';

const users = [
  { "index": 0, "age": 33, "name": "Sandy Saunders", "gender": "female" },
  { "index": 1, "age": 24, "name": "Eloise Meyers",  "gender": "female" },
  { "index": 2, "age": 34, "name": "Joan Rios",      "gender": "female" },
  { "index": 3, "age": 23, "name": "Abbott Peters",  "gender": "male" },
  { "index": 4, "age": 34, "name": "Mai Miles",      "gender": "female" }
];

const pred = R.where({ age: (age) => age > 30, gender: R.equals('female') });

const query = R.pipe(
  R.filter(pred),
  R.map(R.props(['index', 'age', 'name']))
);

query(users); // [[0, 33, "Sandy Saunders"], [2, 34, "Joan Rios"], [4, 34, "Mai Miles"]]

렌즈

렌즈는 배열과 객체처럼 R.prop를 통해서 접근할 수 있는 구조를 다룹니다. 필드를 읽거나 일부를 수정할 때 사용할 수 있습니다.

생성

렌즈는 기본적으로 데이터를 읽는 함수와 변경하는 함수의 쌍으로 만들어집니다. 하지만 렌즈는 읽는 값과 변경하는 값이 대부분의 경우 같기 때문에 lensPath 함수를 사용하는 것이 일반적입니다.

lensPath는 path와 비슷하게 key의 배열을 받아 렌즈를 생성합니다.

import * as R from 'ramda';

const object = { x: { y: 20 } };
const lens = R.lensPath(['x', 'y']);

사용법

렌즈를 만들면 기존의 path처럼 객체를 읽을 수 있습니다. 여기에 추가적으로 path로 접근하고 있는 부분만 바꾼 새로운 객체를 만들 수 있습니다.

이는 복잡하게 얽힌 객체를 다룰때 특히 유용합니다.

view

만들어진 렌즈를 통해 객체에서 값을 읽는 함수입니다. lensPath로 렌즈를 만들고 view로 객체를 들여다 보는 것은 path 함수와 결과가 같습니다.

import * as R from 'ramda';

const object = { x: { y: 20 } };
const lens = R.lensPath(['x', 'y']);

R.view(lens, object);       // 20
R.path(['x', 'y'], object); // 20

set, over

중첩된 객체를 일부만 수정하는 것은 어렵지 않습니다. 하지만 일부만 수정된 새로운 객체를 얻는 것은 만만치 않습니다.

렌즈와 set 혹은 over를 사용하면 렌즈가 가리키고 있는 부분의 데이터만 변경된 새로운 객체를 얻을 수 있습니다.

import * as R from 'ramda';

const object = { x: { y: 20 } };
const lens = R.lensPath(['x', 'y']);

R.set(lens, 42, object);     // { x: { y: 42 } };
R.over(lens, R.inc, object); // { x: { y: 21 } };

set은 렌즈가 가리키고 있는 부분을 다른 값으로 바꿉니다. 위의 코드를 보면 lens는 object의 y를 가리키고 있고, 이 값을 42로 바꿨습니다.

over는 set과 비슷하지만 값이 아닌 함수를 사용합니다. 가리키고 있는 값을 읽은 뒤 함수를 적용시켜 결과값을 바꿉니다.

Redux에 적용

Redux는 불변을 유지하면서 변경할 부분에 대한 데이터를 요구합니다. 이런 경우 렌즈를 사용할 수 있습니다.

import * as R from 'ramda';

const xLens = R.lensPath(['counter', 'value', 'x']); 
const yLens = R.lensPath(['counter', 'value', 'y']); 

const incX = R.over(xLens, R.inc);
const incY = R.over(yLens, R.inc);

const resetX = R.set(xLens, 0);
const resetY = R.set(yLens, 0);

const state0 = { counter: { value: { x: 0, y: 0 } } };
const stateA = incX(state0);   // { counter: { value: { x: 1, y: 0 } } };
const stateB = incY(stateA);   // { counter: { value: { x: 1, y: 1 } } };
const stateC = incY(stateB);   // { counter: { value: { x: 1, y: 2 } } };
const stateD = incY(stateC);   // { counter: { value: { x: 1, y: 3 } } };
const stateE = resetX(stateD); // { counter: { value: { x: 0, y: 3 } } };
const stateF = resetY(stateE); // { counter: { value: { x: 0, y: 0 } } };