Applicative 알아보기

김장훈·2023년 3월 28일
0

fp-ts function 탐구

목록 보기
3/5

1. Interface

  • Applicative 는 of, ap(=apply) function을 갖고 있습니다.
  • Monad 가 Functor 에서 flatmap 을 구현한 타입인것 처럼 Applicative 역시 Functor 에서 ap 를 구현한 타입입니다.
  • Functor > Applicative > Monad 와 같은 계층 구조라고 생각하면 편합니다.

1.1. signature

<T>(f: HKT<F, (a: A) => T>): (fa: HKT<F, A>) => HKT<F, T>
  • f 를 받아서 function 을 return 라는 고차 함수 입니다.
f:HKT<F, (a:A) => T>)
  • 함수를 받습니다.
  • 이 함수는 A 타입을 T 로 변경하고 이후 HKT 형태로 lift 합니다.
  • 즉 1 -> EIther.right("1") 과 같은 결과를 만들어낸다고 이해하시면 됩니다.

1.2. function 상세

2. 어디에 쓰이는가?

  • sequence(E.Applicative) 와 같은 code를 이전 블로그 에서 보곤 했습니다.
    어떤 이유때문에 이런 code 가 작성 되었고 Applicative 를 사용하는 이유는 무엇일까요?

2.1. unwrapping

  • 부수효과를 다루기 위한 강력한 type 인 monad 는 모두 functor 입니다. functor 은 직접적인 값의 접근을 제어하며 값을 wrapping 해놓습니다(container)
  • 이는 여러가지 장점을 지니고 있지만 한가지 불편한 점(단점이라 하기에는 조금 ...) 이라 한다면 값을 다루기 위해선 항상 unwrapping 을 해야한다는 것 입니다.
import * as O from 'fp-ts/Option';
const value1 = O.some(1);
const value2 = O.some(2);
  • 위 두가지 값을 더하는 방법은 여러가지가 있겠지만 pipe 를 사용한 예시로 간단히 하자면 아래와 같을 것 입니다.
    const add = (a: number, b: number) => a + b;
    const res = pipe(
      [value1, value2],
      O.fromNullable,
      O.map((data) => {
        const one = pipe(
          data[0],
          O.getOrElse(() => 0)
        );
        const two = O.getOrElse(() => 0)(data[1]); // pipe 사용 대신 직접 호출
        return add(one, two);
      })
    );
    expect(res).toEqual(O.some(3));
  • 단순히 두개의 값을 더하기 위해서 매우 긴 code 가 나왔습니다.
  • 이를 applicative 를 활용하면 아래와 같이 바꿀 수 있습니다.
    const curriedAdd = (a: number) => (b: number) => a + b;
    const appRes = O.Applicative.ap(
      O.Applicative.map(value1, curriedAdd),
      value2
    );
    expect(appRes).toEqual(O.some(3));
  • 물론 add 함수가 curried 가 되어야하고 모양 자체가 이해하기 쉬운 형태는 아니지만 ... 이렇게도 사용할 수 있습니다.
  • Applicative 의 핵심은 of, ap 를 통한 unwrap 및 apply 정도라고 이해하시면 될 것 같습니다. 그리고 위의 예시는 사용하는 방법에 대한 것일뿐 실제 저런식으로 사용하지는 않을거라 생각합니다(아마도 ...)
  • Applicative 에 대한 활용은 Sequence 에서 찾을 수 있습니다.

2.2. Sequence Arguments

  • 이전 포스트에서 봤었던 Sequence interface 를 다시 봐보겠습니다.
// Array.js
var sequence = function (F) {
    return function (ta) {
        return _reduce(ta, F.of((0, exports.zero)()), function (fas, fa) {
            return F.ap(F.map(fas, function (as) { return function (a) { return (0, function_1.pipe)(as, (0, exports.append)(a)); }; }), fa);
        });
    };
};
  • 고차함수로서 function 을 받고 function 을 return 합니다.
  • 내부 함수를 보면 F.of, F.ap 등이 사용되고 있는것을 볼 수 있습니다! 네 바로 이 부분이 applicative 입니다. of, ap, map 을 통해서 value 를 wrap(=lift) 하고 apply 하는 등의 형태를 띄고 있습니다.
  • 그렇기에 아래와 같은 형태가 가능했습니다.
sequence(O.Applicative)([O.some(1), O.some(2)])
  • 그리고 Applicative 및 method 는 아래처럼 생겼습니다.
// Option.js
/**
 * @category instances
 * @since 2.7.0
 */
exports.Applicative = {
    URI: exports.URI,
    map: _map,
    ap: _ap,
    of: exports.of
};

/**
 * @category mapping
 * @since 2.0.0
 */
var map = function (f) { return function (fa) {
    return (0, exports.isNone)(fa) ? exports.none : (0, exports.some)(f(fa.value));
}; };

/**
 * @since 2.0.0
 */
var ap = function (fa) { return function (fab) {
    return (0, exports.isNone)(fab) ? exports.none : (0, exports.isNone)(fa) ? exports.none : (0, exports.some)(fab.value(fa.value));
}; };
  • 세세하게 보지 않더라고 O.None 인 경우에 대한 대비가 되어있죠. 그렇기에 sequence 에 None 이 있는 경우 None 만 반환했던 것 입니다.
    • Either 도 마찬가지일 것 입니다.
sequence(O.Applicative)([O.some(1), O.some(2), O.none]) // None

3. 응용

⚠️ 아래 코드 및 설명은 의미론적로만 접근했을뿐 실제 interface 등은 제대로 보지 않았기 때문에 다를 수 있습니다. 이런것이구나 정도로만 받아들이면 좋겠습니다.

  • sequence 는 wrapping 되어있는 값들의 형변환을 담당합니다. Monad< T>[] 를 Monad<T[]> 로 변경합니다.
  • Applicative 는 wrapping 되어있는 값들의 결합 및 적용 등을 담당합니다(of, ap 등 사용)
  • 따라서 sequence(Applicative) 는 collcection 형태의 값들을 주어진 방법(=Applicative) 에 따라서 하나의 collection 으로 형변환 할 수 있는것을 뜻 합니다.
  • 이전 포스팅에서 작성한 내용을 활용해보도록 하겠습니다.

3.1. accumlate fail case

	pipe(
      { email: 'my@mail.com', age: 44, gender: 'male'},
      ({ email, age, gender }) =>
        sequenceS(E.Applicative)({
          email: validateEmail(email),
          age: validateAge(age),
          gender: validateGender(gender),
        }),
      console.log
    );  
  • 이전 포스팅에서 validate 하는 code 를 작성하였습니다.

  • validate 는 3개를 하므로 3가지의 결과 값이 나와야하는데 만약 이 중 1개만 left 가 될 경우 sequence 는 이를 하나의 left 로만 만들어 버립니다.

  • 이렇게 될 경우 3가지 모두 잘못된 값을 주고받는 상황에서의 최악의 case 는 아래와 같을 것 입니다.

    	0. 잘못된 data 입력
    	1. 전송 -> email fail
    	2. email 수정 후 전송 -> age fail
    	3. age 수정 후 전송 -> gender fail
  • 최초 data 를 바탕으로 한번에 validation 을 했다면 이런 case 가 발생하지 않았을 겁니다.

  • 이는 단순히 validation case 뿐만 아니라 어떠한 작업을 나눠서 실행 후 결과값을 모으는 case 에 모두 적용할 수 있겠습니다.

  • 그렇기에 우리는 이러한 fail case 의 결과값을 모으기 위해서 sequence 와 applicative 를 사용할 수 있습니다.

  it('using case: accumulate error', () => {
    const res = pipe(
      { email: 'email', age: 20, gender: 'm' },
      ({ email, age, gender }) =>
        sequenceS(E.getApplicativeValidation(getSemigroup<string>()))({
          email: E.left(['email error']), // left가 return 되었다는 가정
          age: E.left(['age error']),
          gender: E.left(['gender error']),
        }),
      E.getOrElseW((err) => err)
    );
    expect(res).toEqual(['email error', 'age error', 'gender error']);
  });
  • 기존에는 error 가 하나만 나오던 부분이 이제는 array 로 나오게 됩니다.
  • 다만 sequence 쪽 코드가 상당히 어려워 보입니다. 이 부분은 정말 간단히 소개만 하겠습니다.

3.1.1. getApplicativeValidation

  • validation 을 활용하는 applicative 를 가져옵니다.
    • validation 은 either 과 같은 monad 이며 success, failure 를 가지고 있습니다. 또한 얘는 mutiple failure 를 다룰 수 있습니다(either 는 오직 한개)
  • 즉 우리는 either 를 valiation 처럼 사용할 수 있는 applicative를 가져오는 것 입니다.

3.1.2. getSemigroup()

  • string 형태의 semigroup instance 를 가져옵니다.
    • semigroup 은 쉽게 말하면 같은 type 끼리의 결합(concat 활용)을 보장하는 type 입니다.
  • semigroup 이 필요한 이유는 left value 를 하나로 합칠때 필요로 하기 때문 입니다.
  • 얘를 사용해야할 때 주의해야할 점은 type 이 보장되어야한다는 점 입니다. value 가 있어야 하므로 fp-ts 에선 이를 NonEmptyArray 타입을 통해 알려줍니다.
  • 즉 left value 를 합해지기 위해선 아래와 같이 변해야 합니다.
  E.left('error') // X
  E.left(['error']) // O,   Either<NonEmptryArray<string>>, unknown>  
  • 만약 left 의 type 이 다른 경우 custom type 을 만들어서 사용하면 됩니다.
    type errorType = string | number
    getSemigroup<errorType>()

3.1.3. sequenceS(E.getApplicativeValidation(getSemigroup< string>())) 이란

  • 위 code 를 풀어서 설명하면 다음과 같습니다.
  1. 각각의 either value 를 하나의 either value 로 변환할것이다(=sequence)
  2. either value 들 중 left 도 multiple 하게 다룰 수 있도록 한다(=getApplicativeValidation)
  3. either value 중 right 는 type 에 상관 없으나 left 인 경우 string 타입만 다룰 수 있다(=getSemigroup< string>

3.1.4. 모든 것을 적용한 code

  const validateEmail = (email: string) => {
    if (!email.includes('@')) {
      return E.left({ reason: 'not proper email' });
    }
    return E.right(email);
  };

  const validateAge = (age: number) => {
    console.log(age);
    if (age <= 20) {
      return E.left({ reason: 'not adult' });
    }
    return E.right(age);
  };

  const validateGender = (gender: string) => {
    console.log(gender);
    if (!['male', 'female'].includes(gender)) {
      return E.left({ reason: 'not proper gender type' });
    }
    return E.right(gender);
  };
  
  
 it('using case: accumulate error', () => {
    const wrapper = (
      value: E.Either<any, unknown>
    ): E.Either<NonEmptyArray<string>, any> => {
      return pipe(
        value,
        E.mapLeft((error) => [error.reason])
      );
    };
 
    const res = pipe(
      { email: 'email', age: 20, gender: 'm' },
      ({ email, age, gender }) =>
        sequenceS(E.getApplicativeValidation(getSemigroup<string>()))({
          email: wrapper(validateEmail(email)),
          age: wrapper(validateAge(age)),
          gender: wrapper(validateGender(gender)),
        }),
      E.getOrElseW((err) => err)
    );
    expect(res).toEqual([
      'not proper email',
      'not adult',
      'not proper gender type',
    ]);
  });
  • validate function 의 결과값을 NonEmptryArray< T> 형태로 만들어야했기에 wrapper 를 만들어 적용시켰습니다.

4. 정리

  • 우리는 sequence 를 통해서 monad 와 같은 values 를 한개의 value 로 변경 시킬 수 있습니다.
  • 변환할때 어떠한 행동(applicative)를 주느냐에 따라서 결과가 달라집니다.

4.1. 개인적 생각

  • 위 code 를 보면 알겠지만 원하는 것을 하기 위해 알아야 하는게 너무 많습니다. 너무.
    • 이러한 목적을 하기 위해서 이렇게 까지 해야하나? 싶은 생각도 듭니다. 다만 이건 제가 아직 모르는 부분이 많기 때문에 더 쉬운 방법이 존재할 수도 있습니다.
  • 그렇기에 함수형 프로그래밍을 쉽게 접근하지 못하는 이유가 이런것 때문이겠거니 라는 생각이 많이 듭니다.
    • 러닝커브가 높기 때문에 오히려 잘못 쓸수 없다 라는 말이 있는 정도니까요.

이번에 학습한 개념들(sequence, applicative, semigroup) 은 정말 어렵긴 했습니다. 아직 더 배워야하는게 많은데 걱정이네요.

  • 남은것들 : traverse, validation, monoid

읽어주셔서 감사합니다.

profile
읽기 좋은 code란 무엇인가 고민하는 백엔드 개발자 입니다.

0개의 댓글