
This post is a personal summary after reading Multiparadigm Programming (by Yoo In-dong).
what is multiparadigm?
paradigm is a system of perception, or a theoretical framework or system of ideas about things, as a framework that fundamentally defines the views or thinking of people at a given time. - wikipedia
early programming languages focused on a single paradigm. for example, C is a procedural language, and Java is an object-oriented language. However, modern languages are not designed to use only one paradigm.
i'm not arguing that one is better than the other. they are simply tools for better, more successful software development. some problems may be better solved with a procedural approach, some with an object-orientation, and some with a functional approach.
the real value of multiparadigms is the flexibility to choose the mindset that best fits the problem. being able to utilize different approaches in different contexts, rather than being locked into a single paradigm, is a great way to deal with the complexity of modern software development.
iterators and first-class functions in object-oriented design patterns
in traditional object-oriented languages, the Iterator Pattern was used to implement lazy iteration protocols. later, with the addition of first-class functions, the core of the functional paradigm, helper functions such as map
, filter
, and reduce
could be implemented based on this pattern.
this combination of object-oriented design patterns and the core functional paradigm's core concept of first-class functions gave rise to the programming tools of lazy evaluation and declarative list processing.
The Iterator Pattern in GoF
the Iterator Pattern provides a way to access all items in a collection without exposing how the collection is implemented.
a collection is a collection of objects, sometimes called an aggregate.
the advantage of using the Iterator Pattern is that the iterator object, rather than the collection, is responsible for providing access to each item.
here's a quick look at the interface of the iterators that make up the iterator pattern
interface IteratorYieldResult<T> {
done?: false;
value: T;
}
interface IteratorReturnResult {
done: true;
value: undefined;
}
interface Iterator<T> {
next(): IteratorYieldResult<T> | IteratorReturnResult;
}
iterators are lazy
iterators support lazy evaluation, which allows you to fetch a value only when you need it.
this property, along with memory efficiency, allows you to process data in a variety of ways without changing the original data.
for example, if you want to implement reverse
that traverses an array in reverse order, you can do so by modifying the iterator's next()
without flipping the original array.
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
the single role principle
the key to the iterator pattern is to provide a consistent way to access all items without exposing the internal structure of the collection. by letting the iterator object handle the traversal logic instead of changing the original data, each class has a single responsibility. this is an implementation that naturally follows the Single Responsibility Principle, an important principle of object-oriented design.
the map function is lazily evaluated
map
function is a function that takes a transform
function that converts Iterator<A>
and A
to B
and returns a lazy Iterator<B>
.
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 };
},
};
}
the code treats functions as values and accepts them as parameters, which is a characteristic of first-order and higher-order functions, which are key components of the functional programming paradigm.
the laziness of the iterator pattern allows us to create objects that evaluate lazily, and the first-class function allows us to create higher-order functions. as a result, the two can be combined to implement advanced list processing that deals with lazy or lazy-evaluated lists such as map
, filter
, take
, reduce
, etc.
generator functions to create iterators with imperative programming
we've seen the synergy between object-oriented design patterns and functional programming's first-class functions. now let's see how the imperative programming paradigm joins the party.
leveraging the strengths of imperative programming - procedural flow control - generator functions make iterators more intuitive to implement. this is an important example of multiparadigm programming, where three paradigms - object-oriented, functional, and imperative - work together to complement each other's strengths.
generators Basic Syntax
Introduced in ES6, generators are a syntax that allows you to write iterators imperatively, defined by the function*
keyword, and return an iterator object when called instead of executing directly. this object allows you to externally control the flow of the function.
if you call the next()
method on the returned iterator object, it will execute until it encounters yield
in the body of the function.
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 }
since generators are implemented imperatively, you can also use conditional statements like the following
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 }
inside the generator function, you can use the yield*
keyword to return an iterable object. javaScript considers arrays to be iterable objects, so you can traverse them via yield*
as shown in the example below.
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 }
utilizing lazy evaluation in generators
in the example below, the naturals
generator function uses an infinite loop to generate a natural number, but it only returns n
when iter.next()
is called and then stops again, so the process or browser doesn't freeze.
this is because iterators have the property of lazy evaluation, and generators return iterators, which causes the code to run lazily.
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 }
// 계속해서 호출할 수 있습니다.
example of the iterator pattern in JavaScript: Iteration Protocols
Introduced in ES6, the iteration protocol is a pre-promised convention defined in the ECMAScript specification for creating traversable data collections.
Prior to ES6, traversable data could be traversed in a variety of ways without a unified convention, each with its own structure, such as for
statements, for...in
statements, forEach
methods, and so on. ES6 unifies iterables that conform to the iteration protocol so that they can be used as targets for for...of
statements, spread grammars, and parse assignments.
there are two iteration protocols: the iterable protocol and the iterator protocol.
iterators and Iterables
Symbol.iterator
when you implement a method with Symbol.iterator
as a property key, or call a method inherited from through the prototype chain, it returns an object that conforms to the iterator protocol and is called an iterable. thus, a regular object becomes iterable when it is implemented to conform to the iterable protocol.
an iterable object can be used with a variety of features, including for...of
statements, expansion operators, and structural decomposition.
- iterator: a value with a
next()
method that returns a{ value, done }
object - iterable: a value with a
[Symbol.iterator]()
method that returns an iterator - iterable iterator: a value that is both an iterator and iterable
- iteration protocols: conventions for making iterables work with
for...of
, expansion operators, etc
the iterator pattern is designed to be traversed without changing the internal class, so you can use features like expansion operators and structural decomposition regardless of whether you're traversing an array, set, or object.
custom Iterables
implement your own iterable protocol to create a method filter
.
it goes through an infinite loop and calls next()
until done
becomes false. along the way, it returns yeild
only if the value satisfies the conditional function f
.
1. implementation with generator functions
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. implementation using recursive calls
you can also implement it by recursively calling yourself without a loop, as shown below. however, there is a risk of stack overflow in large collections.
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. implementation with tail-call optimization
to optimize tailing, you can reorganize the recursive function with a while statement like this
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;
},
};
}
interface instead of inheritance
Note
inheritance: inherits both the configuration and implementation of an existing class.
interface: defines only the signatures needed for implementation.
let's look at the NodeList object in the JavaScript DOM API. this object implements the iterable protocol, so you can naturally use syntax like for...of statements and expansion operators. however, even though NodeList looks like an array, you can't use methods like map or filter on it, because NodeList doesn't inherit from Array.
since they are data structures that require traversal, why don't they inherit from Array?
think back to the single responsibility principlementioned in thelaziness of iterators section. a class should only play one role, and you should avoid modifying it as much as possible. if NodeList
inherits from Array
, then if it ever needs to fix Array
, it has to think about the side effects that will be passed on to NodeList
. this makes it harder and harder to modify the code.