# FP$2-Modular-PipelineAndCompose

119 阅读11分钟

FP$2-Modular-PipelineAndCompose

A complex system that works is invariably found to have evolved from a simple system that worked.
—John Gall, The Systems Bible (General Systemantics Press, 2012)

Modularity is one of the most important qualities of large software projects; it represents the degree to which programs can be separated into smaller, independent parts.

模块化有两个好处:

  • 功能明确。把所有功能写在一个文件里会非常乱。把所有代码写在一个函数里也有同样的问题。模块化意味着单一功能 Single Responsibility。
  • 便于复用。正是因为第一点,模块化后文件 / 代码就像一块砖一样,哪里需要搬哪里。函数作为一个“模块”,要想实现哪里需要搬哪里,就需要是纯函数。

模块化工作分为两部分:

  1. 设计模块化元组件
    1. 设计“真正的”元祖件(理想状况是 unary)
    2. 将一些不是元祖件的组件转化成元祖件
      1. currying
      2. partial
  2. 使用元祖件
    1. pipeline / compose
    2. 其他 combinators

1. Method chains vs. function pipelines

Viewing functions as mappings of types is necessary to understand how they can be chained and pipelined:

  • Chaining methods together (tightly coupled, limited expressiveness)
  • Arranging function pipelines (loosely coupled, flexible)

1.1 Chaining methods together

Chaining mothods 的问题是 OOP,chain 上的方法取决于 the owning object。

// After each “dot,” you can only invoke other methods from the Lodash managed chain.
_.chain(names)
  .filter(isValid)
  .map((s) => s.replace(/_/, " "))
  .uniq()
  .map(_.startCase)
  .sort()
  .value();

1.2 Arranging functions in a pipeline

A pipeline is a directional sequence of functions loosely arranged so that the output of one is input into the next.

Function pipeline pattern is equivalent to the pipes and filters object-oriented design pattern seen in many enterprise applications, which was inspired by functional programming (the filters in this case became the individual functions).

2. Requirements for compatible functions

The connecting functions must be compatible in terms of arity and type:

  • Type—The type returned by one function must match the argument type of a receiving function.
  • Arity—A receiving function must declare at least one parameter in order to handle the value returned from a preceding function call.

2.1 Type-compatible functions

// Listing 4.1 Building a manual function pipeline with trim and normalize
 // trim :: String -> String
 const trim = (str) => str.replace(/^\s*|\s*$/g, '');
 // normalize :: String -> String
 const normalize = (str) => str.replace(/\-/g, '');
 normalize(trim(' 444-44-4444 ')); //-> '444444444'

2.2 Functions and arity: the case for tuples

Arity can be defined as the number of arguments a function accepts; it’s also referred to as the function’s length.

Functional languages have support for a structure called a tuple. It’s a finite, ordered list of elements, usually grouping two or three values at a time, and written (a, b, c).

// isValid :: String -> (Boolean, String)
isValid(' 444-444-44444'); //-> (false, 'Input is too long!')

Tuple or Object:

return {
 status : false,
 message: 'Input is too long!'
};
// or
return [false, 'Input is too long!'];

Tuples offer more advantages:

  • Immutable—Once created, you can’t change a tuple’s internal contents.
  • Avoid creating ad hoc types—Tuples can relate values that may have no relationship at all to each other. So defining and instantiating new types solely for grouping data together makes your model unnecessarily convoluted.
  • Avoid creating heterogeneous arrays—Working with arrays containing different types of elements is hard because it leads to writing code filled with lots of defensive type checks. Traditionally, arrays are meant to store objects of the same type.

In Scala:

var t = (30, 60, 90)
var sumAnglesTriangle = t._1 + t._2 + t._3 = 180

在书中作者实现并使用了 Tuple 类。使用分为两步:

  1. 调用函数生成某一类型的 Tuple 类。
  2. new 这个具体的 Tuple 类生成对象。 这样的好处是可以在第一步的类型里声明 Tuple类,这样就形成了“链式结构”。
// Listing 4.2 Typed Tuple data type
const Tuple = function ( /* types */) {
  const typeInfo = Array.prototype.slice.call(arguments, 0);
  const _T = function ( /* values */) {
    const values = Array.prototype.slice.call(arguments, 0);
    if (values.some((val) =>
      val === null || val === undefined)) {
      throw new ReferenceError('Tuples may not haveany null values');
    }
    if (values.length !== typeInfo.length) {
      throw new TypeError('Tuple arity does not match its prototype');
    }
    values.map(function (val, index) {
      this['_' + (index + 1)] = checkType(typeInfo[index])(val);
    }, this);
    Object.freeze(this);
  };
  _T.prototype.values = function () {
    return Object.keys(this).map(function (k) {
      return this[k];
    }, this);
  };
  return _T;
}
// Tuple 基本使用
const StringPair = Tuple(String, String);
const name = new StringPair('Barkley', 'Rosser');
[first, last] = name.values();
first; //-> 'Barkley'
last; //-> 'Rosser'

JavaScript 现在还没有 Tuple 类型。TypeScript 的 Tuple 类和数组有些像,不像上面的分为两步。

3. Curried function evaluation

Difference between a curried and a regular (non-curried) evaluation:

  • In JavaScript, a regular or non-curried function call is permitted to execute with missing arguments, and the JavaScript runtime sets the unassigned to undefined.

  • A curried function is one where all arguments have been explicitly defined so that, when called with a subset of the arguments, it returns a new function that waits for the rest of the parameters to be supplied before running.

    Currying is a technique that converts a multivariable function into a stepwise sequence of unary functions by suspending or “procrastinating” its execution until all arguments have been provided, which could happen later.

curry(f) :: ((a,b,c) -> d) -> a -> b -> c -> d

3.0 Manually curry

// Listing 4.5 Manual currying with two arguments
function curry2(fn) {
  return function (firstArg) {
    return function (secondArg) {
      return fn(firstArg, secondArg);
    };
  };
}
const name = curry2((last,first) => new StringPair(last,first));
[first, last] = name('Curry')('Haskell').values();
first;//-> 'Curry'
last; //-> 'Haskell'
name('Curry'); //-> Function
// checkType :: Type -> Object -> Object
const checkType = R.curry((typeDef, obj) => {
  if(!R.is(typeDef, obj)) {
    let type = typeof obj;
    throw new TypeError(`Type mismatch. Expected
      [${typeDef}] but found [${type}]`);
  }
  return obj;
});

checkType(String)('Curry'); //->'Curry'
checkType(Number)(3); //-> 3
checkType(Number)(3.5); //-> 3.5

let now = new Date();
checkType(Date)(now);  //-> now
checkType(Object)({}); //-> {}
checkType(String)(42); //-> TypeError

R.curry for any number.

3.1 Usage

柯里化 curry 其实是 to convert multiargument functions into unary functions。在确认的过程中,这些参数就确认了。如果把这个过程图例化,就是从一棵树中选择一条路到叶。

1. Emulating function factories

Factory method pattern 工厂方法模式的一个例子是不同的工厂生产不同的类似产品。比如工厂用来生产鞋,鞋分为足球鞋、篮球鞋、跑鞋、休闲鞋等多种类型。

解决方式是声明一个抽象类 ShoeFactoryAbstract,其中定义了抽象方法 makeShoes。再声明其他的具体类来继承并实现具体的方法。比如 ShoeFactoryFootballmakeShoes 实现生产 football shoes;ShoeFactoryBasketballmakeShoes 实现生产 basketball shoes。(创建类)

在使用的时候我们会调用 getShoeFactory,根据具体的环境不同,返回的 shoeFactory 会不同,但是无论返回什么 shoeFactory,都可以调用 makeShoes 方法,而且返回的都是 shoe。(使用类)

书里使用的是 interface + implements,我不清楚里面的例子算不算是工厂方法模式。在我看来,那只是接口的使用,虽然看起来和工厂方法模式的用法一样,但是由于没有公共基类,缺少了公共基类提供的基本功能。另外,书里有点矛盾。标题是 Emulating function factories,标题上面的内容是 Emulating function interfaces。考虑到 FP 本就弱化数据结构,所以算作 function factories 也还行。

Java function factories
public interface StudentStore {
  Student findStudent(String ssn);
}
public class DbStudentStore implements StudentStore {
  public Student findStudent(String ssn) {
    // ... 
    ResultSet rs = jdbcStmt.executeQuery(sql);
    while(rs.next()) {
      String ssn = rs.getString("ssn");
      String name = rs.getString("firstname") + rs.getString("lastanme");
      return new Student(ssn, name);
    }
  }
}
public class CacheStudentStore implements StudentStore {
  public Student findStudent(String ssn) {
    // ...
    return cache.get(ssn);
  }
}
StudentStore store = getStudentStore(); 
store.findStudent("444-44-4444");
JavaScript curry factories
// fetchStudentFromDb :: DB -> (String -> Student)
const fetchStudentFromDb = R.curry(function (db, ssn) { 
	return find(db, ssn); 
});
// fetchStudentFromArray :: Array -> (String -> Student)
const fetchStudentFromArray = R.curry(function (arr, ssn) { 
	return arr[ssn]; 
});
const findStudent = useDb ? fetchStudentFromDb(db) : fetchStudentFromArray(arr);
findStudent("444-44-4444");

2. Implementing reusable function templates

// Listing 4.6 Creating a logger function template
const logger = function (appender, layout, name, level, message) {
  const appenders = {
    alert: new Log4js.JSAlertAppender(),
    console: new Log4js.BrowserConsoleAppender(),
  };
  const layouts = {
    basic: new Log4js.BasicLayout(),
    json: new Log4js.JSONLayout(),
    xml: new Log4js.XMLLayout(),
  };
  const appender = appenders[appender];
  appender.setLayout(layouts[layout]);
  const logger = new Log4js.getLogger(name);
  logger.addAppender(appender);
  logger.log(level, message, null);
};
const log = R.curry(logger)('alert', 'json', 'FJS'); 
log('ERROR', 'Error condition detected!!'); 
// -> this will popup an alert dialog with the requested messag
const logError = R.curry(logger)('console', 'basic', 'FJS', 'ERROR');
logError('Error code 404 detected!!');
logError('Error code 402 detected!!'); 

4. Partial application and parameter binding

Partial application 和 curry 的作用一样,但是表现形式不同:

  • curry 只需要执行一次,返回的函数(一元函数)就可以一直调用直到返回结果。
  • partial 则是返回一个较少参数的函数,这个返回的函数是普通函数。如果想再次减少参数,需要再次通过 partial 调用该函数。

Partial application is an operation that initializes a subset of a nonvariadic function’s parameters to fixed values, creating a function of smaller arity. In simpler terms, if you have a function with five parameters, and you supply three of the arguments, you end up with a function that expects the last two.

Currying & partial application:

  • Currying generates nested unary functions at each partial invocation. Internally, the final result is generated from the step-wise composition of these unary functions. Also, variations of curry allow you to partially evaluate a number of arguments; therefore, it gives you complete control over when and how evaluation takes place.
  • Partial application binds (assigns) a function’s arguments to predefined values and generates a new function of fewer arguments. The resulting function contains the fixed parameters in its closure and is completely evaluated on the subsequent call.
// Listing 4.7 Implementation of partial
function partial() {
  let fn = this,
    boundArgs = Array.prototype.slice.call(arguments);
  // let placeholder = <<partialPlaceholderObj>>;
  // Implementations of partial in libraries such as Lodash use the underscore object as the placeholder.
  // Other ad hoc implementations use undefined to suggest this parameter should be skipped.
  let placeholder = undefined;
  let bound = function () {
    let position = 0,
      length = boundArgs.length;
    let args = Array(length);
    for (let i = 0; i < length; i++) {
      args[i] =
        boundArgs[i] === placeholder ? arguments[position++] : boundArgs[i];
    }
    while (position < arguments.length) {
      args.push(arguments[position++]);
    }
    return fn.apply(this, args);
  };
  return bound;
}
const consoleLog = _.partial(logger, 'console', 'json', 'FJS Partial');
const consoleInfoLog = _.partial(consoleLog, 'INFO'); // need to use partial again
consoleInfoLog('INFO logger configured with partial');

function binding:

const log =_.bind(logger, undefined, 'console', 'json', 'FJS Binding');
log('WARN', 'FP is too awesome!');

4.1 Extending the core language

Partial application can be used to extend core data types like String and Number with useful utilities than enhance the expressiveness of the language. Just be mindful that extending the language this way may make your code less portable to platform upgrades if new, conflicting methods are added to the language.

import _ from "lodash";
// Take the first N characters
String.prototype.first = _.partial(String.prototype.substring, 0, _);
// Using a placeholder, you can partially apply substring starting at index zero and create a function that expects an offset value.
"Functional Programming".first(3); // -> 'Fun'
// Convert any name into a Last, First format
String.prototype.asName = _.partial(
  String.prototype.replace,
  /(\w+)\s(\w+)/,
  "$2, $1"
);
"Alonzo Church".asName(); //-> 'Church, Alonzo'
// Converts a string into an array
String.prototype.explode = _.partial(String.prototype.match, /[\w]/gi);
"ABC".explode(); //-> ['A', 'B', 'C']
// Parses a simple URL
String.prototype.parseUrl = _.partial(
  String.prototype.match,
  /(http[s]?|ftp):\/\/([^:\/\s]+)\.([^:\/\s]{2,5})/
);
"http://example.com".parseUrl(); // -> [ 'http://example.com', 'http', 'example', 'com' ]
if (!String.prototype.explode) {
  String.prototype.explode = _.partial(String.prototype.match, /[\w]/gi);
}

There are cases where partial application doesn’t work, such as when you’re working with delayed functions like setTimeout. For this, you need to use function binding.

下面的例子,如果把 bind 换成 partial, 去掉 undefined,在 node 中仍然可以正常运行,在浏览器中则报错。在浏览器中,this 丢了。

4.2 Binding into delayed functions

import _ from "lodash";
const Scheduler = (function () {
  const delayedFn = _.bind(setTimeout, undefined, _, _);
  return {
    delay5: _.partial(delayedFn, _, 5000),
    delay10: _.partial(delayedFn, _, 10000),
    delay: _.partial(delayedFn, _, _),
  };
})();
Scheduler.delay5(function () {
  console.log("Executing After 5 seconds!");
});

5. Composing function pipelines

The intention of functional programs is to gain the required structure that leads to composition, the backbone of functional programming.

5.1 Understanding composition with HTML widgets

组合 composition 就是拼积木。把一个整体拆分成多个个体,之后再拼起来。

Objects with simple behavior (which don’t have external dependencies) compose fairly well and can be used to build complex structures from simple ones, like interlocking building blocks.

To demonstrate, let’s create a recursive tuple definition called Node:

const Node = Tuple(Object, Tuple);
const element = R.curry((val, tuple) => new Node(val, tuple));
const grades = element(1, element(2, element(3, element(4, null))));

5.2 Functional composition: separating description from evaluation

In essence, functional composition is a process used to group together complex behavior that has been broken into simpler tasks.

const str = `We can only see a short distance ahead
but we can see plenty there 
that needs to be done`;
const explode = (str) => str.split(/\s+/);
const count = (arr) => arr.length;
const countWords = R.compose(count, explode);
countWords(str); //-> 19

The interesting quality of this program is that evaluation never takes place until countWords is run. This is the beauty of function composition: separating a function’s description from its evaluation.

g :: A -> B
f :: B -> C
f ● g = f(g) = compose :: ((B -> C), (A -> B)) -> (A -> C)

Programming to interfaces. In the previous example, you have a function explode :: String -> [String] composed with the function count :: [String] -> Number; in other words, each function only knows or cares about the next function’s interface and isn’t worried about its implementation.

// Listing 4.8 Implementation of compose
function compose(/* fns */) {
  let args = arguments;
  let start = args.length - 1;
  return function () {
    let i = start;
    let result = args[start].apply(this, arguments);
    while (i--) result = args[i].call(this, result);
    return result;
  };
}

Luckily, Ramda provides an implementation of R.compose that you can use so you don’t have to implement this yourself. A validation program that checks for a valid SSN:

const trim = (str) => str.replace(/^\s*|\s*$/g, "");
const normalize = (str) => str.replace(/\-/g, "");
const validLength = (param, str) => str.length === param;
const checkLengthSsn = _.partial(validLength, 9);

const cleanInput = R.compose(normalize, trim);
const isValidSsn = R.compose(checkLengthSsn, cleanInput);
cleanInput(" 444-44-4444 "); //-> '444444444'
isValidSsn(" 444-44-4444 "); //-> true

Composition is a conjunctive operation, which means it joins elements using a logical AND operator. For instance, the function isValidSsn is made from checkLengthSsn and cleanInput. In this manner, programs are derivations of the sum of all their parts. In chapter 5, we’ll tackle problems that require disjunctive behavior to express conditions where functions can return one of two results, A OR B.

5.3 Composition with functional libraries

// find the student with the highest grade in the class
const students = ['Rosser', 'Turing', 'Kleene', 'Church'];
const grades = [80, 100, 90, 99];
// Listing 4.9 Computing the smartest student
const smartestStudent = R.compose(
  R.head,
  R.pluck(0),
  R.reverse,
  R.sortBy(R.prop(1)),
  R.zip
);
smartestStudent(students, grades); //-> 'Turing'

Liabrary R's functions:

  • R.zip—Creates a new array by pairing the contents of adjacent arrays. In this case, pairing these two arrays yields [['Rosser', 80], ['Turing', 100], ...].
  • R.prop—Specifies the value to be used in sorting. In this case, passing a 1 points to the second element of a subarray (grade).
  • R.sortBy—Performs a natural ascending sort of the array by the given property.
  • R.reverse—Reverses the entire array to get the highest number at the first element.
  • R.pluck—Builds an array by extracting an element at a specified index. Passing a 0 points to the student name element.
  • R.head—Takes the first element.
// Listing 4.10 Using descriptive function aliases
const first = R.head;
const getName = R.pluck(0);
const reverse = R.reverse;
const sortByGrade = R.sortBy(R.prop(1));
const combine = R.zip;
R.compose(first, getName, reverse, sortByGrade, combine);

5.4 Coping with pure and impure code

const showStudent = compose(append, csv, findStudent);

One way or another, most of these functions emit side effects through the arguments they receive:

  • findStudent uses a reference to a local object store or some external array.
  • append directly writes and modifies HTML elements.
// Listing 4.11 showStudent program using currying and composition
// findObject :: DB -> String -> Object
const findObject = R.curry((db, id) => {
  const obj = find(db, id);
  if (obj === null) {
    throw new Error(`Object with ID [${id}] not found`);
  }
  return obj;
});
// findStudent :: String -> Student
const findStudent = findObject(DB("students"));
const csv = ({ ssn, firstname, lastname }) =>
  `${ssn}, ${firstname}, ${lastname}`;
// append :: String -> String -> String
const append = R.curry((elementId, info) => {
  document.querySelector(elementId).innerHTML = info;
  return info;
});

// showStudent :: String -> Integer
const showStudent = R.compose(
  append("#student-info"),
  csv,
  findStudent,
  normalize,
  trim
);
showStudent("44444-4444"); //-> 444-44-4444, Alonzo, Church
const showStudentPipe = R.pipe(
  trim,
  normalize,
  findStudent,
  csv,
  append("#student-info")
);

showStudentPipe("44444-4444"); //-> 444-44-4444, Alonzo, Church

Functional composition encourages this writing style (function 'without' arguments) , which goes by the name of point-free coding.

5.5 Introducing point-free programming

If you look closer at the following function, you can see that it doesn’t show the parameters of any of its constituent functions, as would a traditional function declaration.

R.compose(first, getName, reverse, sortByGrade, combine);

Using compose (or pipe) means never having to declare arguments (known as the points of a function), making your code declarative and more succinct or point-free.

Point-free programming brings functional JavaScript code closer to that of Haskell and the Unix philosophy. It can be used to increase the level of abstraction by forcing you to think of composing high-level components instead of worrying about the low-level details of function evaluation. Currying plays an important role because it gives you the flexibility to partially define all but the last argument of an inlined function reference. This style of coding is also known as tacit programming, much like the Unix program from the start of the chapter, which is written next in a point-free way.

// Listing 4.12 Point-free version of a Unix program using Ramda functions
const runProgram = R.pipe(R.map(R.toLower), R.uniq, R.sortBy(R.identity));
runProgram([
  "Functional",
  "Programming",
  "Curry",
  "Memoization",
  "Partial",
  "Curry",
  "Programming",
]);
//-> [curry, functional, memoization, partial, programming]

6. Managing control flow with functional combinators

Combinators are higher-order functions that can combine primitive artifacts like other functions (or other combinators) and behave as control logic. Combinators typically don’t declare any variables of their own or contain any business logic; they’re meant to orchestrate the flow of a functional program. In addition to compose and pipe, there’s an infinite number of combinators, but we’ll look at some of the most common ones:

  • identity
  • tap
  • alternation
  • sequence
  • fork (join)

6.1 Identity (I-combinator)

The identity combinator is a function that returns the same value it was provided as an argument:

identity :: (a) -> a

It’s used extensively when examining the mathematical properties of functions, but it has other practical applications as well:

  • Supplying data to higher-order functions that expect it when evaluating a function argument, as you did earlier when writing point-free code (listing 4.12).
  • Unit testing the flow of function combinators where you need a simple function result on which to make assertions (you’ll see this in chapter 6). For instance, you could write a unit test for compose that uses identity functions.
  • Extracting data functionally from encapsulated types (more on this in the next chapter).

6.2 Tap (K-combinator)

tap is extremely useful to bridge void functions (such as logging or writing a file or an HTML page) into your composition without having to any create additional code. It does this by passing itself into a function and returning itself. Here’s the function signature:

tap :: (a -> *) -> a -> a

This function takes an input object a and a function that performs some action on a. It runs the given function with the supplied object and then returns the object.

For instance, using R.tap, you can take a void function like debugLog:

const debugLog = _.partial(logger, 'console', 'basic', 'MyLogger', 'DEBUG');
const debug = R.tap(debugLog);
const cleanInput = R.compose(normalize, debug, trim);
const isValidSsn = R.compose(debug, checkLengthSsn, debug, cleanInput);
isValidSsn('444-44-4444');
// output
MyLogger [DEBUG] 444-44-4444 // clean input
MyLogger [DEBUG] 444444444 // check length
MyLogger [DEBUG] true // final result

6.3 Alternation (OR-combinator)

The alt combinator allows you to perform simple conditional logic when providing default behavior in response to a function call.

This combinator takes two functions and returns the result of the first one if the value is defined (not false, null, or undefined); otherwise, it returns the result of the second function. Let’s implement it here (what about 0 and ''?):

const alt = function (func1, func2) {
  return function (val) {
    return func1(val) || func2(val);
  };
};
const alt = R.curry((func1, func2, val) => func1(val) || func2(val));
const showStudent = R.compose(
  append("#student-info"),
  csv,
  alt(findStudent, createNewStudent)
);
showStudent("444-44-4444");
var student = findStudent("444-44-4444");
if (student !== null) {
  let info = csv(student);
  append("#student-info", info);
} else {
  let newStudent = createNewStudent("444-44-4444");
  let info = csv(newStudent);
  append("#student-info", info);
}

6.4 Sequence (S-combinator)

The seq combinator is used to loop over a sequence of functions. It takes two or more functions as parameters and returns a new function, which runs all of them in sequence against the same value. This is the implementation:

const seq = function (/*funcs*/) {
  const funcs = Array.prototype.slice.call(arguments);
  return function (val) {
    funcs.forEach(function (fn) {
      fn(val);
    });
  };
};
const showStudent = R.compose(
  seq(append("#student-info"), consoleLog),
  csv,
  findStudent
);

The seq combinator doesn’t return a value; it just performs a set of actions one after the other. If you want to inject it into the middle of a composition, you can use R.tap to bridge the function with the rest.

6.5 Fork (join) combinator

The fork combinator is useful in cases where you need to process a single resource in two different ways and then combine the results. This combinator takes three functions: a join function and two terminal functions that process the provided input. The result of each forked function is ultimately passed in to a join function of two arguments.

const fork = function (join, func1, func2) {
  return function (val) {
    return join(func1(val), func2(val));
  };
};
const computeAverageGrade = R.compose(
  getLetterGrade,
  fork(R.divide, R.sum, R.length)
);
computeAverageGrade([99, 80, 89]); //-> 'B'
const eqMedianAverage = fork(R.equals, R.median, R.mean);
eqMedianAverage([80, 90, 100])); //-> True
eqMedianAverage([81, 90, 100])); //-> False

总结

FP 就是把大的任务分割成小的任务,之后再串起来:

  • 使用 pipeline 或者 compose 将小函数串起来
  • 为了保持小函数之间的链接,可以使用 currying 或者 partial 减少函数的参数(当然类型也需要对应)
  • combinators 是关于逻辑的工具函数
  • 整体串完后的函数看起来没有参数,这种形式叫作 point-free coding。