挑战100天跳槽大厂计划--day02 函数式编程

0 阅读8分钟

从命令式到函数式:一场编程范式的进化之旅

接上一篇模块化,今天我们来聊聊函数式编程。这是大厂面试中区分「普通开发」和「资深开发」的分水岭之一。

一、编程范式的发展历程

编程范式的演进,本质上是人类对「复杂性管理」的认知升级:

text

命令式(脚本式)→ 面向对象 → 函数式编程
范式核心思想代表语言解决的问题
命令式一步一步告诉计算机「怎么做」C、汇编、JavaScript(基础写法)机器可理解
面向对象封装数据和行为,用对象交互Java、C++、Python代码复用与组织
函数式用函数组合来表达「做什么」Haskell、Scala、Clojure副作用管控与可测试性

注意:这三种范式不是「取代关系」,而是「互补关系」。现代 JavaScript 往往是「函数式 + 面向对象」的混合体。

二、面试真题:用函数式编程思维解决实际问题

题目:有一个订单列表,需要筛选出 VIP 用户的订单,然后计算这些订单的总金额,最后对总金额打 9 折。

命令式写法(传统):

javascript

function calculateVipTotal(orders) {
    let total = 0;
    for (let i = 0; i < orders.length; i++) {
        if (orders[i].user.isVip) {
            total += orders[i].amount;
        }
    }
    return total * 0.9;
}

函数式写法(面试加分项):

javascript

// 纯函数:筛选VIP订单
const filterVipOrders = orders => orders.filter(order => order.user.isVip);

// 纯函数:计算总金额
const sumAmount = orders => orders.reduce((sum, order) => sum + order.amount, 0);

// 纯函数:打折
const applyDiscount = (amount, discount = 0.9) => amount * discount;

// 组合使用
const calculateVipTotal = orders => applyDiscount(sumAmount(filterVipOrders(orders)));

// 或者使用 compose(后面会讲)
const calculateVipTotal = compose(applyDiscount, sumAmount, filterVipOrders);

面试官追问:两种写法有什么区别?

维度命令式函数式
可读性需要理解循环逻辑读函数名就知道在做什么
可测试性需要整个函数一起测每个小函数可单独测试
可复用性逻辑耦合,难以复用每个函数都可独立复用
副作用修改了 total 变量无状态变更

三、函数式编程的五大核心特点

【特点1】函数是一等公民

函数可以像变量一样被赋值、作为参数传递、作为返回值返回。

javascript

// 1. 赋值给变量
const sayHello = () => console.log('hello');

// 2. 作为参数传递(高阶函数)
[1,2,3].map(x => x * 2);

// 3. 作为返回值返回
function createMultiplier(multiplier) {
    return function(x) {
        return x * multiplier;
    };
}
const double = createMultiplier(2);
console.log(double(5)); // 10

面试考点:高阶函数(接收函数或返回函数的函数)

【特点2】声明式编程

告诉计算机「我要什么」,而不是「怎么一步步做」。

javascript

// 命令式:告诉计算机「怎么做」
const result = [];
for (let i = 0; i < numbers.length; i++) {
    if (numbers[i] > 5) {
        result.push(numbers[i] * 2);
    }
}

// 声明式:告诉计算机「要什么」
const result = numbers
    .filter(n => n > 5)   // 我要大于5的
    .map(n => n * 2);      // 然后乘以2

面试考点:声明式代码更贴近人类语言,可读性更高,bug 更少

【特点3】惰性函数

根据条件在运行时「重写」自己,常用于性能优化和条件分流。

javascript

// 普通写法:每次调用都要判断
function getApiUrl() {
    if (isDevEnvironment()) {
        return 'http://localhost:3000/api';
    } else {
        return 'https://api.prod.com';
    }
}

// 惰性函数:第一次执行后「固定」下来
function getApiUrl() {
    if (isDevEnvironment()) {
        getApiUrl = () => 'http://localhost:3000/api';
    } else {
        getApiUrl = () => 'https://api.prod.com';
    }
    return getApiUrl();
}

// 或者更简洁的写法:立即执行函数 + 惰性
const getApiUrl = (() => {
    if (isDevEnvironment()) {
        return () => 'http://localhost:3000/api';
    }
    return () => 'https://api.prod.com';
})();

面试考点:惰性函数常用于浏览器 API 兼容性检测、环境判断等场景

【特点4】无状态(幂等性)

相同的输入,永远产生相同的输出,不依赖外部状态。

javascript

// ❌ 有状态:依赖外部变量
let taxRate = 0.1;
function calculatePrice(price) {
    return price * (1 + taxRate);
}

// ✅ 无状态:输入决定输出
function calculatePrice(price, taxRate) {
    return price * (1 + taxRate);
}

面试考点:幂等性是函数式编程的基石,也是分布式系统中重试机制的前提

【特点5】无副作用

函数内部不修改外部变量、不读写文件、不发送网络请求。

javascript

// ❌ 有副作用:修改了外部变量
let total = 0;
function addToTotal(value) {
    total += value;
    return total;
}

// ❌ 有副作用:修改了传入的参数
function addTax(price) {
    price.tax = price.value * 0.1;  // 直接修改了原对象
    return price;
}

// ✅ 无副作用:返回新对象
function addTax(price) {
    return {
        ...price,
        tax: price.value * 0.1
    };
}

四、函数式编程的数学原理

函数式编程的底层思想源自数学:通过原子的组合来构建复杂逻辑

text

加法结合律: (a + b) + c = a + (b + c)
因式分解:   a*b + a*c = a*(b + c)
完全平方式: (a + b)² = a² + 2ab + b²

对应到编程:

javascript

// 结合律:函数组合的顺序不影响结果
compose(f, compose(g, h)) === compose(compose(f, g), h)

// 因式分解:提取公共逻辑
// 不因式分解
const withLogging = fn => (...args) => {
    console.log('调用开始');
    const result = fn(...args);
    console.log('调用结束');
    return result;
};

// 因式分解后的复用
const createLoggingWrapper = (before, after) => fn => (...args) => {
    before?.();
    const result = fn(...args);
    after?.();
    return result;
};

五、流水线加工:柯里化(Currying)

柯里化的本质:将多参数函数转换成一系列单参数函数的链式调用。

javascript

// 普通函数
f(x, y, z) => 结果

// 柯里化后
f(x)(y)(z) => 结果

手写面试题:可拆分解传参的累加函数

javascript

// 题目:实现 add(1)(2)(3) 返回 6
// 进阶:add(1)(2)(3)(4) 返回 10

function add(...args) {
    // 累加器函数
    const accumulator = (...newArgs) => {
        // 合并参数
        const allArgs = [...args, ...newArgs];
        
        // 递归返回新的累加函数
        const resultFn = (...nextArgs) => accumulator(...allArgs, ...nextArgs);
        
        // 重写 toString/valueOf,在需要值时返回累加结果
        resultFn.toString = () => allArgs.reduce((sum, num) => sum + num, 0);
        resultFn.valueOf = () => allArgs.reduce((sum, num) => sum + num, 0);
        
        return resultFn;
    };
    
    return accumulator(...args);
}

// 测试
console.log(add(1)(2)(3));        // 6
console.log(add(1)(2)(3)(4));     // 10
console.log(add(1,2)(3,4)(5));    // 15(支持混合传参)

更简洁的版本(适合手写):

javascript

function add(...args) {
    const sum = args.reduce((a, b) => a + b, 0);
    
    const fn = (...next) => add(sum, ...next);
    fn.valueOf = () => sum;
    fn.toString = () => sum;
    
    return fn;
}

// 使用
console.log(add(1)(2)(3).valueOf());  // 6

面试官追问:为什么要实现 valueOf 和 toString

因为柯里化函数返回的是一个函数,当我们需要获取数值结果时,JavaScript 会自动调用 valueOf 或 toString 进行类型转换。

六、流水线组装:Compose(函数组合)

Compose 的本质:将多个函数「串联」成一条流水线,数据从左到右依次经过每个函数。

text

compose(f, g, h)(x) = f(g(h(x)))

手写 Compose

javascript

// 从右向左执行(数学上的复合函数)
function compose(...fns) {
    return function(initialValue) {
        return fns.reduceRight((value, fn) => fn(value), initialValue);
    };
}

// 从左向右执行(更符合阅读习惯,也叫 pipe)
function pipe(...fns) {
    return function(initialValue) {
        return fns.reduce((value, fn) => fn(value), initialValue);
    };
}

// 使用示例
const toUpper = str => str.toUpperCase();
const addExclamation = str => str + '!';
const repeat = str => str.repeat(2);

const emphasize = compose(addExclamation, toUpper);
console.log(emphasize('hello'));  // 'HELLO!'

const emphasizeMore = pipe(toUpper, addExclamation, repeat);
console.log(emphasizeMore('hello'));  // 'HELLO!HELLO!'

面试考点:Compose 是函数式编程的「胶水」,让小的纯函数组合成复杂的业务逻辑。

七、完整面试题示例

题目:实现一个 calculate 函数,支持链式调用,最终输出计算结果。

javascript

// 期望用法
calculate(1)
    .add(2)      // 3
    .multiply(3) // 9
    .subtract(4) // 5
    .getValue(); // 5

// 实现(函数式风格)
function calculate(initialValue) {
    let value = initialValue;
    
    const operations = {
        add: (x) => {
            value = value + x;
            return operations;  // 返回自身,支持链式调用
        },
        multiply: (x) => {
            value = value * x;
            return operations;
        },
        subtract: (x) => {
            value = value - x;
            return operations;
        },
        getValue: () => value
    };
    
    return operations;
}

// 更函数式的版本(无状态,每次返回新对象)
function calculateV2(initialValue) {
    return {
        add: (x) => calculateV2(initialValue + x),
        multiply: (x) => calculateV2(initialValue * x),
        subtract: (x) => calculateV2(initialValue - x),
        getValue: () => initialValue
    };
}

八、今日思考题答案

回顾昨天的思考题:

javascript

// 原代码(有问题)
let discount = 0.9;
let price = 100;
function calculate() {
  if (user.isVip) {
    discount = 0.8;
  }
  return price * discount;
}

问题分析:

  1. 依赖外部变量 discountpriceuser(有状态)
  2. 修改了外部变量 discount(有副作用)
  3. 相同输入可能产生不同输出(非幂等)
  4. 难以测试(需要模拟全局 user)

函数式改造:

javascript

// 方案1:纯函数
function calculate(price, user, baseDiscount = 0.9) {
    const finalDiscount = user.isVip ? 0.8 : baseDiscount;
    return price * finalDiscount;
}

// 方案2:柯里化 + 组合
const getDiscount = user => user.isVip ? 0.8 : 0.9;
const calculatePrice = price => discount => price * discount;

const finalPrice = (price, user) => calculatePrice(price)(getDiscount(user));

// 方案3:更函数式的做法
const isVip = user => user.isVip;
const vipDiscount = () => 0.8;
const normalDiscount = () => 0.9;
const getDiscount = user => isVip(user) ? vipDiscount() : normalDiscount();
const multiply = x => y => x * y;

const calculate = (price, user) => multiply(price)(getDiscount(user));

九、今日手写代码汇总

javascript

// 1. 柯里化工具函数
function curry(fn) {
    return function curried(...args) {
        if (args.length >= fn.length) {
            return fn.apply(this, args);
        }
        return function(...next) {
            return curried.apply(this, [...args, ...next]);
        };
    };
}

// 使用:将普通函数转成柯里化
const add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3));  // 6

// 2. Compose 和 Pipe
const compose = (...fns) => x => fns.reduceRight((v, fn) => fn(v), x);
const pipe = (...fns) => x => fns.reduce((v, fn) => fn(v), x);

// 3. 惰性函数(实际应用:浏览器API检测)
const getLocalStorage = (() => {
    try {
        localStorage.setItem('test', 'test');
        localStorage.removeItem('test');
        return () => localStorage;
    } catch(e) {
        return () => {
            console.warn('当前环境不支持 localStorage');
            return { setItem: () => {}, getItem: () => null };
        };
    }
})();

十、面试要点总结

概念一句话总结面试常见问题
一等公民函数可以当变量用什么是高阶函数?
声明式说「要什么」不说「怎么做」命令式和声明式的区别?
惰性函数第一次运行后「重写自己」惰性函数的应用场景?
无状态/幂等相同输入永远相同输出为什么分布式系统需要幂等?
无副作用不改变外部世界什么是纯函数?为什么重要?
柯里化f(x,y) → f(x)(y)手写柯里化函数
组合多个小函数拼成一个大函数手写 compose/pipe

下一篇预告:我们进入「异步编程」的世界,从回调地狱到 Promise,再到 async/await,彻底搞懂 JavaScript 的异步演进之路。