멀티패러다임이 현대 언어를 확장하는 방법

profile image

멀티패러다임 프로그래밍을 읽고 현대 프로그래밍 언어가 멀티패러다임 접근법을 통해 어떻게 진화하고 확장되는지 이터레이터와 일급 함수등의 예제를 통해 살펴봅니다.

멀티패러다임 프로그래밍

해당 포스트는 멀티패러다임 프로그래밍 (유인동 저)을 읽고 개인적으로 정리한 글입니다.

멀티패러다임이란?

패러다임(paradigm)은 어떤 한 시대 사람들의 견해나 사고를 근본적으로 규정하고 있는 테두리로서의 인식의 체계, 또는 사물에 대한 이론적인 틀이나 체계를 의미하는 개념이다.
- 위키백과

초기 프로그래밍 언어들은 주로 하나의 패러다임에 집중하였다. 예를 들어 C 언어는 절차지향의 대표적인 언어이고, Java는 객채지향의 대표적인 언어이다. 그러나 요즘 사용하는 언어들은 하나의 패러다임만을 사용하게 설계되지 않았다.

각각의 패러다임은 내가 더 나음을 주장하는 것이 아니다. 단지 더 나은, 성공적인 소프트웨어 개발을 위한 도구일 뿐이다. 문제에 대하여 어떤 경우는 절차지향이, 어떤 경우는 객체지향이, 또 어떤 경우는 함수형지향이 더 효과적인 방벙일 수 있는 것이다.

즉, 멀티패러다임의 진정한 가치는 문제에 가장 적합한 사고방식을 선택할 수 있는 유연성에 있다. 하나의 패러다임에 갇히지 않고, 상황에 맞게 다양한 접근법을 활용할 수 있다는 것은 현대 소프트웨어 개발의 복잡성을 다루는 데 큰 방향이 될 것이다.

객체지향 디자인 패턴의 반복자 패턴과 일급 함수

기존 객체지향 언어에서는 반복자 패턴 (Iterator Pattern)을 통해 지연성 있는 이터레이션 프로토콜을 구현했다. 이후 함수형 패러다임의 핵심인 일급 함수가 추가되면서 이 패턴을 바탕으로 map, filter, reduce 와 같은 헬퍼 함수들이 구현될 수 있었다.

이렇게 객체지향의 디자인 패턴과, 함수형의 핵심 개념인 일급 함수가 만나 지연 평가와 선언적 리스트 프로세싱이라는 프로그래밍 도구를 탄생시켰다.

GoF의 반복자 패턴

반복자 패턴(Iterator Pattern)은 집합체의 구현 방법을 노출하지 않으면서 컬렉션 내의 모든 항목에 접근하는 방법을 제공한다.

컬렉션은 객체를 모아놓은 것으로, 집합체(aggregate) 라고 부르기도 한다.

반복자 패턴을 사용하면 각 항목에 접근할 수 있게 해주는 기능을 컬렉션이 아닌 반복자 객체가 책임진다는 장점이 있다.

반복자 패턴을 구성하는 이터레이터의 인터페이스를 간략하게 살펴보면 다음과 같다.

typescript
interface IteratorYieldResult<T> {
  done?: false;
  value: T;
}

interface IteratorReturnResult {
  done: true;
  value: undefined;
}

interface Iterator<T> {
  next(): IteratorYieldResult<T> | IteratorReturnResult;
}

이터레이터의 지연성

이터레이터는 필요할 때만 값을 꺼낼 수 있는 지연 평가(lazy evaluation) 을 지원한다.

이 특성은 메모리 효율성과 함께 원본 데이터를 변경하지 않고도 다양한 방식으로 데이터를 처리할 수 있게 해준다.

예를 들어, 배열을 역순으로 순회하는 reverse를 구현한다고할 때 원본 배열을 뒤집지 않고, 이터레이터의 next()를 수정하면 된다.

typescript
function reverse<T>(arrayLike: ArrayLike<T>): Iterator<T> {
  let idx = arrayLike.length;
  return {
    next() {
      if (idx === 0) {
        return { value: undefined, done: true };
      } else {
        return { value: arrayLike[--idx], done: false };
      }
    },
  };
}

const array = ['A', 'B'];
const reversed = reverse(array);
console.log(array); // ['A', 'B'] (원본 배열은 그대로)

console.log(reversed.next().value, reversed.next().value);
// B A

Note

단일 역할 원칙
반복자 패턴의 핵심은 컬렉션의 내부 구조를 노출하지 않으면서도 모든 항목에 접근할 수 있는 일관된 방법을 제공하는 것이다. 원본 데이터를 변경하는 대신 이터레이터 객체가 순회 로직을 담당함으로써 각 클래스가 단일 책임을 갖게 된다. 이는 객체지향 설계의 중요 원칙인 단일 책임 원칙(Single Responsibility Principle)을 자연스럽게 따르는 구현 방식이다.

지연 평가되는 map 함수

map 함수는 Iterator<A>AB로 변환하는 transform 함수를 받아 지연된 Iterator<B>를 반환하는 함수다.

typescript
function map<A, B>(transform: (value: A) => B, iterator: Iterator<A>): Iterator<B> {
  return {
    next(): IteratorResult<B> {
      const { value, done } = iterator.next();
      return done ? { value, done } : { value: transform(value), done };
    },
  };
}

코드를 보면 함수를 값으로 취급하여 매개변수로 받을 수 있는데, 이는 함수형 프로그래밍 패러다임의 핵심 구성요소인 일급 함수고차 함수의 특성이다.

반복자 패턴의 지연성이 지연 평가되는 객체를 만들 수 있게 하고, 일급 함수는 고차 함수를 만들 수 있게 한다. 결과적으로 이 둘을 조합하여 map, filter, take, reduce 등의 지연 평가되거나 지연 평가된 리스트를 다루는 고도화된 리스트 프로세싱을 구현할 수 있다.

명령형 프로그래밍으로 이터레이터를 만드는 제네레이터 함수

앞서 객체지향의 디자인 패턴과 함수형 프로그래밍의 일급 함수가 결합해 시너지를 창출하는 것을 살펴봤다. 이제는 여기에 명령형 프로그래밍 패러다임이 어떻게 합류하는지 알아보자.

명령형 프로그래밍의 강점인 절차적 흐름 제어를 활용한 제네레이터 함수는 이터레이터를 더욱 직관적으로 구현할 수 있게 해준다. 이는 객체지향, 함수형, 명령형 세 가지 패러다임이 서로의 장점을 보완하며 협력하는 멀티패러다임 프로그래밍의 중요한 사례이다.

제네레이터 기본 문법

ES6에 도입된 제네레이터는 명령형으로 이터레이터를 작성할 수 있게 해주는 문법으로 function* 키워드로 정의되며 호출 시 바로 실행되지 않고 이터레이터 객체를 반환한다. 이 객체를 통해 함수의 흐름을 외부에서 제어할 수 있게 해준다.

반환된 이터레이터 객체에서 next() 메서드를 호출하면 함수의 본문에서 yield를 만날 때 까지 실행된다.

typescript
function* generator() {
  yield 1;
  yield 2;
  yield 3;
}

const iter = generator();

console.log(iter.next()); // { value: 1, done: false }
console.log(iter.next()); // { value: 2, done: false }
console.log(iter.next()); // { value: 3, done: false }
console.log(iter.next()); // { value: undefined, done: true }

제네레이터는 명령형으로 구현하기 때문에 아래와 같이 조건문도 사용할 수 있다.

typescript
function* generator(condition: boolean) {
  yield 1;
  if (condition) {
    yield 2;
  }
  yield 3;
}

const iter1 = generator(false);

console.log(iter1.next()); // { value: 1, done: false }
console.log(iter1.next()); // { value: 3, done: false }
console.log(iter1.next()); // { value: undefined, done: true }

제네레이터 함수 안에서 yield*키워드를 통해 이터러블한 객체를 반환할 수 있다. 자바스크립트에서는 배열을 이터러블한 객체로 간주하기 때문에 아래 예시처럼 yield*를 통해 순회할 수 있다.

typescript
function* generator() {
  yield 1;
  yield* [2, 3];
  yield 4;
}

const iter = generator();

console.log(iter.next()); // { value: 1, done: false }
console.log(iter.next()); // { value: 2, done: false }
console.log(iter.next()); // { value: 3, done: false }
console.log(iter.next()); // { value: 4, done: false }
console.log(iter.next()); // { value: undefined, done: true }

제네레이터의 지연 평가 활용

아래 예제에서 naturals 제너레이터 함수는 무한 루프를 사용하여 자연수를 생성하지만, iter.next()를 호출할 때만 n을 반환한 후 다시 멈추기 때문에 프로세스나 브라우저가 멈추지 않는다.

즉, 이터레이터는 지연평가되는 특성을 가지고 있고 제네레이터는 이터레이터를 반환하기 때문에 코드를 지연 실행 시킨다.

typescript
function* naturals() {
  let n = 1;
  while (true) {
    yield n++;
  }
}

const iter = naturals();

console.log(iter.next()); // { value: 1, done: false }
console.log(iter.next()); // { value: 2, done: false }
console.log(iter.next()); // { value: 3, done: false }
// 계속해서 호출할 수 있습니다.

자바스크립트에서 반복자 패턴 사례: 이터레이션 프로토콜

ES6에서 도입된 이터레이션 프로토콜은 순회 가능한 데이터 컬렉션을 만들기 위해 ECMAScript 사양에 정의하여 미리 약속한 규칙이다.

ES6 이전에는 순회 가능한 데이터들이 통일된 규약 없이 각자 나름의 구조를 가지고 for 문, for...in 문, forEach 메서드 등 다양한 방법으로 순회할 수 있었다. ES6 에서는 이터레이션 프로토콜을 준수하는 이터러블로 통일하여 for...of 문, 스프레드 문법, 구조분해 할당의 대상으로 사용할 수 있도록 일원화 했다.

이터레이션 프로토콜에는 이터러블 프로토콜과 이터레이터 프로토콜이 있다.

iterable-iterator.png

이터레이터와 이터러블

Symbol.iterator 를 프로퍼티 키로 사용한 메서드를 직접 구현하거나 프로토타입 체인을 통해 상속받은 Symbol.iterator 메서드를 호출하면 이터레이터 프로토콜을 준수한 객체를 반환하며 이 객체를 이터러블이라 한다. 따라서 일반 객체도 이터러블 프로토콜을 준수하도록 구현하면 이터러블이 된다.

이터러블 객체는 for...of 문, 전개 연산자, 구조 분해 등 다양한 기능과 함께 사용할 수 있다.

  • 이터레이터: { value, done } 객체를 리턴하는 next() 메서드를 가진 값
  • 이터러블: 이터레이터를 리턴하는 [Symbol.iterator]() 메서드를 가진 값
  • 이터러블이터레이터: 이터레이터면서 이터러블인 값
  • 이터레이션 프로토콜: 이터러블을 for...of, 전개 연산자 등과 함께 동작하도록 한 규약

반복자 패턴은 내부 클래스의 변화 없이 순회할 수 있도록 설계되었기에 순회하고자 하는 대상이 배열이든, Set이든, 객체이든 상관없이 전개 연산자나 구조 분해등의 기능을 사용할 수 있다.

사용자 정의 이터러블

직접 이터러블 프로토콜을 구현하여 filter 메서드를 만들어보자.

무한 반복문을 돌면서 done 이 false가 될 때 까지 next() 를 호출한다. 그러면서 조건 함수 f 를 만족하는 값만 yeild 하여 반환한다.

1. 제네레이터 함수를 활용한 구현

typescript
function* filter(f, iterable) {
  const iterator = iterable[Symbol.iterator]();
  while (true) {
    const { value, done } = iterator.next();
    if (done) break;
    if (f(value)) {
      yield value;
    }
  }
}

const array = [1, 2, 3, 4, 5];
const filtered = filter((x) => x % 2 === 0, array);
console.log([...filtered]); // [2, 4]

2. 재귀 호출을 활용한 구현

아래 처럼 반복문 없이 자기 자신을 재귀 호출하여 구현할 수도 있다. 하지만 큰 컬렉션에서는 스택 오버플로우 위험이 있다.

typescript
function filter(f, iterable) {
  const iterator = iterable[Symbol.iterator]();
  return {
    next() {
      const { done, value } = iterator.next();
      if (done) return { done, value }; // (3)
      if (f(value)) return { done, value }; // (1)
      return this.next(); // recursive
    },
    [Symbol.iterator]() {
      return this;
    },
  };
}

console.log(...filter((x) => x % 2 === 1, [1, 2, 3, 4, 5])); // 1 3 5

3. 꼬리 호출 최적화를 고려한 구현

꼬리 물기 최적화를 위해 재귀 함수를 다음과 같이 while문을 이용해 재구성할 수 있다.

typescript
function filter(f, iterable) {
  const iterator = iterable[Symbol.iterator]();
  return {
    next() {
      while (true) {
        const { done, value } = iterator.next();
        if (done) return { done, value };
        if (f(value)) return { done, value };
      }
    },
    [Symbol.iterator]() {
      return this;
    },
  };
}

상속 대신 인터페이스

Note

상속: 기존 클래스의 구성과 구현을 모두 물려받는다.
인터페이스: 구현에 필요한 시그니처만을 정의한다.

자바스크립트 DOM API에서 NodeList 객체를 살펴보자. 이 객체는 이터러블 프로토콜을 구현했기 때문에 for...of 문이나 전개 연산자 같은 구문을 자연스럽게 사용할 수 있다. 하지만 NodeList가 배열처럼 보이지만 Array의 map이나 filter 같은 메서드는 사용할 수 없다는 것이다. 왜냐하면 NodeList는 Array를 상속받지 않았기 때문이다.

순회가 필요한 자료구조들인데 왜 Array를 상속받지 않았을까?

이터레이터의 지연성 부분에서 언급한 단일 책임 원칙을 생각해보면 된다. 클래스는 하나의 역할만 맡아야 하고, 클래스를 고치는 일은 최대한 피해야 한다. 만약 NodeListArray 를 상속받았을 때, Array 를 고칠일 이 생기면 NodeList 에 전해질 사이드 이펙트까지 생각을 해야 한다. 이렇게 되면 코드를 수정하기 점점 더 어려워 진다.

❤️ 0
🔥 0
😎 0
⭐️ 0
🆒 0