[함수형 프로그래밍] 모나드

Heechul Yoon·2022년 8월 15일
0

학문적으로 모나드를 이해해보려 했지만 짧은 시간에 모나드를 이해하기는 쉽지 않았다. 그래서 모나드가 주는 가치에 대해서 실제로 모나드가 어떻게 사용되는지에 대해서 알아보면 이해가 좀 더 쉬울 수 있다.
함수형 프로그래밍은 함수의 합성을 통해서 프로그램을 이어나가는 패러다임이다. 함수를 합성할때의 문제는 리턴타입과 다음 함수에 넘겨주는 인자가 맞추어 주는것에 있다.
모나드는 이 부분에서 함수가 합성을 이어나갈 수 있는 방법을 제시한다.

우리가 자주 사용하는 javascript 에서의 Array도 모나드이다.

[1, 2].map(a => a + 1).map(a => a - 1).forEach(a => console.log(a); // 1
[].map(a => a + 1).map(a => a - 1).forEach(a => console.log(a); // 2

[1, 2] 배열은 은 모나드이고, 이 모나드에 map 매서드가 구현되어 있다.
하지만 2에서 처럼 배열에 아무것도 없으면 map 함수를 돌릴 수 없지만 애러가 나진 않는다.

즉, 값이 잘못들어왔을 때(지금의 경우는 map을 돌릴 값이 없을 때) 함수를 실행시켜줄지 어떻게 할지를 판단해주는 buffer 역할을 해주는게 모나드의 역할이다. 그럼 여기서 다시한번 모나드의 개념을 정리하면 “모나드는 함수를 안전하게 합성하기 위한 value를 가지고 있는 객체이다”

Promise는 future Monad 이다

Promise.resolve(e).then(e => e + 1).then(e => e - 1)

여기서 모나드는 Promise.resolve(e) 이매서드의 결과로 리턴된 promise 객체가 모나드 라는 뜻이다.
future Monad라고 한 이유는 Promise.resolve(e) 이게 언제 끝날지 모르는데 미래에 언젠가 끝나고 리턴된 값을 .then(e => e + 1) 에서 잡아다가 연산을 해주겠다는 뜻이다.

Functor

우선 functor랑 monad를 보기 전에 map은 배열을 돌면서 어떤 연산을 해주는게 아니라는걸 알아야 한다
정확히 말하면 map은 Monad의 값에 콜백함수로 받은 함수를 적용시켜주는걸 말한다(참고)
어떤 객체의 안에 있는 값을 내부 메서드 map을 통해서 객체안의 다른 타입으로 바꾸는 것이다. Functer는 map 함수를 들고 있는 인터페이스다

interface Functor<T> {
    map<R>(func: (value: T) => R): Functor<R | null>;
}

Functor를 상속(구현)한 클래스는 map함수를 구현해야한다. Functer를 구현한 Optional 클래스를 만들어보자

class Optional<T> implements Functor<T> {
    private readonly valueOrNull: T;

    constructor(val: T) {
        this.valueOrNull = val;
    }
    
    map<R>(func: (value: T) => R): Optional<R | null> {
        if (this.valueOrNull == null) {
            return new Optional<R | null>(null);
        }
        return new Optional<R | null>(func(this.valueOrNull));
    }
    
    get(): T {
        return this.valueOrNull;
    }
    
    isPersent(): boolean {
        return this.valueOrNull != null;
    }
}

어떤 값 하나를 저장하는 Optional 객체이다. Optional 객체의 특징은 객체안에 있는 멤버변수가 null이 될 수도 있고, 값이 들어가있을 수도 있다. null혹은, 값을 Optional 객체로 감싸주는 어떤 객체의 반환값을 한가지 타입(Optional)로 처리할 수 있기 때문이다

function getData(id: String | null): Optional<String | null> {
   if (id == null) { 
       return new Optional(null);
   } 
   return new Optional(id + ' data')
}

Optional로 wrapping하지 않았다면 null을 따로 신경서야 한다. 디비에서 데이터를 가져올때 값이 없으면 null을 반환할 것이고, null체크를 따로 해주어야 하지만 Optional객체를 반환하면 null을 신경쓰지 않고 같은 타입(Optional)으로 사용할 수 있다.

map함수는 타입이 T이고, 반환의 타입이 R인 콜백함수를 매개변수로 받고, 자기자신의 객체를 반환한다. 타입스크립트 특징상 객체에 null포인터를 넣을 수 없기 때문에 | null 를 통해서 해당 반환값이 null 이 될 수 있음을 명시한다.

이 map 함수의 특징은 반환값이 Functor<R> 이라는거다. 만약 Functor<T> 변수를 생성하고 해당 변수를 통해서 map함수를 실행하면 반환값으로 Functor<R> 가 나온다

즉, Functor<T> 변수가 map 함수를 실행(정확히는 map에 전달되는 콜백함수를 통해서)해서 Functor<R> 타입으로 바뀌었다.

Functor를 사용하는 이유는 같은 외부객체에 특정 기능을 하는 콜백함수를 사용해서 내부객체의 타입을 바꾸기 위해서다

Monad

하지만 Functor에서는 문제점이 있다. map함수에 콜백함수로 넘기는 것의 반환값이 한번 Optional 객체 처럼 한번 wrapping된 클래스라면 wrapping에 wrapping을 한번 더하는 상황이 벌어진다.

예를들어 자바에서 Optional<Integer> trayParse(String s) 이런 메서드가 있으면, 이 메서드를 Optional 객체의 map함수에 콜백함수로 넘겨주게 되면 Optional로 wrapping을 한번 더하게 된다. 함수형 프로그래밍의 특징상 map의 결과를 가지고 다시 map을 호출하는 경우가 많은데, map을 호출할때마다 Optional wrapping이 한번 더 일어나게 되면 안된다. 이것은 함수의 합성과 체이닝을 저해하는 결과를 가져온다.

즉, 반환갑이 Optional<Optional<Integer>> 가 된다. 그래서 Monad는 map대신 flatMap이라는걸 사용한다

interface Monad<T> extends Functor<T> {
    flatMap<R>(func: (value: T) => R): R | null;
}

Functor에서의 map이랑 다른점은 flatMap의 반환타입은 콜백함수에서 받은 반환값과 일치한다. 반면 Functor에서 map의 반환 타입은 콜백함수에서의 반환값을 “자기자신 객체" 로 감싼 값을 반환한다. Monad 인터페이스가 Functor를 상속받았다는건 Functor로서의 기능도 충분히 하고 거기에 flatMap 까지 제공하겠다는거다. 다시 Optional 클래스의 구현체를 보면

class Optional<T> implements Monad<T> {
  private readonly valueOrNull: T;
    
  constructor(val: T) {
    this.valueOrNull = val;
  }
    
  map<R>(func: (value: T) => R): Optional<R | null> {
    if (this.valueOrNull == null) {
      return new Optional<R | null>(null);
    }
    return new Optional<R | null>(func(this.valueOrNull));
  }
    
  flatMap<R>(func: (value: T) => R): R | null {
    return func(this.valueOrNull);
  }
    
  get(): T {
    return this.valueOrNull;
  }
    
  isPersent(): boolean {
    return this.valueOrNull != null;
  }
}

Optional 이라는 이름의 Monad를 구현했다. flapMap 매서드를 보면 반환값이 콜백함수의 반환값과 일치한다. 위에서 확인한 Functor의 문제점을 그대로 해결했다고 보면된다.
매우 많은 함수가 이미 Optional을 반환하도록 구현되어 있기 때문에 콜백함수의 반환값을 그대로 사용해야 함수 체이닝이 가능해진다. 이제 Optional 객체를 반환하는 콜백함수를 flatMap에 넘겨줄 수 있기 때문에 함수 체이닝이 가능해졌다

function parseToNum(str: string | null): Optional<Number | null> {
  const num = Number(str);
    
  if (isNaN(num)) {
    return new Optional(null);
  }
    
  return new Optional(num);
}
    
function parseToString(num: Number | null): Optional<string | null> {
  const str = String(num);
    
  return new Optional(str);
}
    
const val = new Optional('1234')
  .flatMap(parseToNum)
  .flatMap(parseToString)
  .flatMap(parseToNum);
    
console.log(val); // Optional { valueOrNull: '1234' }

map이 쓰였다는건 콜백함수를 통해 리턴된 값이 모나드가 아닌 값이고, flatMap이 쓰였다는건 콜백함수에서 리턴된 값이 모나드값이란걸 뜻한다.

위에서 살표본걸 바탕으로 모나드의 정의를 다시 내려보자

  • 값을 담는 컨테이너의 일종
  • Functor를 기반으로 구현되었음
  • flatMap() 메서드를 제공함
  • Monad Law를 만족하는 객체
profile
Quit talking, Begin doing

0개의 댓글