程序编写技巧:多if-else分支的优化

前言

我记得我在前一篇的Python博客中提到了对多重复杂的if/else分支上的优化,觉得这个内容非常的有意思。这里针对C++的部分,重新整理一下遇到的技巧。

我们很容易会问出来,为什么要优化过度的if/else分支?答案很简单,if/else分支拖的足够长的情况下,我们很难会找到我们想要的分支,而且最麻烦的是,处理机制是写死在代码中的。如果我们想快速更换逻辑,就需要对这一串if/else动外科手术,这就违反开放/封闭原则了。这导致了高耦合与重复逻辑导致维护成本上升,很不好。(笔者认为一个不好的程序设计是——长方法(Long Method)、深嵌套(Deep Nesting)、重复条件、以及条件和行为混杂。除非在非常极限的嵌入式编程中也许我们需要打破一点规矩,但是都那样了,显然汇编才是更加王道的选择。)

笔者总结了三种视角的if/else,一种是通用结构性的,这个优化不管对何种if/else都是管用的;还有两种细分特化——值分发和状态变化+策略分发的if/else

通用结构性的if/else优化

重构策略:守卫子句(Guard Clauses / Early Return)

经典的图灵机指出,我们一个处理子单元可以被至少分解为输入——处理——输出三步走。处理本身自然的产生结果。在处理的时候,我们往往会需要保证输入的合法性(处理存在处理边界,一些输入是我们无法处理的,这就是错误处理,关于错误处理总是是一个庞大的话题,笔者这里悬置。不再这篇博客中讨论。)

经典的反设计模式代码是如下的:

#include <vector>
#include <optional>

struct Order { std::vector<int> items; std::string status; };

void process(const std::optional<Order>& orderOpt) {
	if(orderOpt){
        const Order& order = *orderOpt;
        if(!order.items.empty()){
            if (order.status == "paid") {
                ....
            }
        }
    }
}

看起来好像没有这么夸张对不对?但问题是现实是复杂的,对现实建模,我们很有可能要写上成千上万的if-else条件分支,您肯定不会让您的代码嵌套10000次if-else,更何况,万一您接手的代码压根没有代码缩进。。。这就已经开始造成代码无法阅读了而不是难以理解。

我们注意到,大部分判断是防卫性的判断,我的意思是,检查输入是否合法的,您看:if(orderOpt)判断语法层面上的对象是否有效;if(!order.items.empty())判断清单是否不是空的,我们不会对空的清单处理任何内容;且外,我们不打算处理任何状态不是paid的清单。这些分明是可以提前退出的。所以:**我们把单一职责函数的异常、边界条件、错误分支尽早处理并返回,剩余代码处理正常路径。**这样您快速上手业务的时候,立马跳过所有的前置检查(你知道这些东西跟您处理的逻辑是无关的)

#include <vector>
#include <optional>

struct Order { std::vector<int> items; std::string status; };

void process(const std::optional<Order>& orderOpt) {
    if (!orderOpt) return;
    const Order& order = *orderOpt;
    if (order.items.empty()) return;
    if (order.status != "paid") return;

    // ---------------------------------------------
    // Author Notes: Read from here to start real process
    // ---------------------------------------------
}

重构策略:提取函数(Extract Method)

忘记是哪一本设计模式策略的书籍了,作者有一个很激进的观点,任何超过十行的函数都不应该存在。我们且不论这样的教条是否真的适合应用在软件开发中,但是笔者认为,他的确提出一个有趣的观点——那就是任何复杂逻辑都最好被分解为若干更加具名的函数。我们再也不需要些复杂的逻辑解释代码本身在做什么,函数名就在解释!只需要解释为什么是这个流程,我们更好的把逻辑聚焦在代码表达本身了。因为命名良好的函数本身就是文档。

#include <string>

struct User { bool exists; bool isActive; bool isBanned; std::string role; };

bool isAdminActive(const User* u) {
    return u && u->exists && u->isActive && u->role == "admin" && !u->isBanned;
}

void doAdminTask(User* u) {
    // 管理员任务实现
}

void handle(User* u) {
    if (!isAdminActive(u)) return;
    doAdminTask(u);
}

值策略分发的if/else分发

这个策略专门针对的是值分发的if/else分支。也就是说,我们的处理是这样的:

if(action == "create"){
	process_create();
}else if(action == "process"){
	process_process();
}else if ...

尽管有朋友会说,switch...case...不好嘛?他没有解决根本问题。如果我们现在有10000个值需要处理,显然这个时候的switch...case会跟上面的 if/else 分支流一样难看。当然,笔者认为,出现超过三个else if的时候,上面这样写就变得不太合适了——但是谁有说得好明天你这个地方真的不会出现超过三个else if呢?所以对不稳定的判断分支,不如试试下面的方式?

重构策略:查表/映射(Table-Driven / Map of Functions)

数据结构可以存取值(先不说对象吧),所以基于值选择对应行为的if/else笔者归纳到了值策略分发的if/else分发种类中,所以一个很自然的重构策略产生——利用哈希表来分发值选择的行为。

#include <unordered_map>
#include <functional>
#include <string>
#include <iostream>

using Handler = std::function<void()>;

void createItem() { std::cout << "create" << std::endl; }
void updateItem() { std::cout << "update" << std::endl; }
void deleteItem() { std::cout << "delete" << std::endl; }

int main() {
    std::unordered_map<std::string, Handler> handlers{
        {"create", createItem},
        {"update", updateItem},
        {"delete", deleteItem}
    };

    std::string action = "update";
    auto it = handlers.find(action);
    if (it != handlers.end()) it->second();
    else std::cerr << "unknown action
";
}

现在事情变得非常非常有趣了——你突然发现这个处理可以热插拔处理流程了!如果我们现在不希望handlers在语法层次上禁用掉delete处理流的时候,直接删除这个item就好了!

重构策略:策略模式 / 多态(Strategy / Polymorphism)

广义的讲,对象的种类本身也是一个值,我们根据对象的种类分发一类行为。这是多态的实际含义。如果您的项目中OOP是主要的编程范式,那么把不同分支对应的行为封装成类或对象,实现同一接口化简基于种类分发行为的一个好利器。

#include <memory>
#include <iostream>

struct PaymentStrategy {
    virtual ~PaymentStrategy() = default;
    virtual void pay(double amount) = 0;
};

struct CreditCardPay : PaymentStrategy {
    void pay(double amount) override { std::cout << "Pay by credit card: " << amount << '
'; }
};

struct PaypalPay : PaymentStrategy {
    void pay(double amount) override { std::cout << "Pay by PayPal: " << amount << '
'; }
};
    
std::unique_ptr<PaymentStrategy> get_strategy(std::string_view commands){
    static const std::unordered_map<
        std::string_view, 
    	std::function<std::unique_ptr<PaymentStrategy>()> // factory types 
    > strategies = {
       	{"CreditCardPay", [](){return std::make_unique<CreditCardPay>();}}
        ....
    };
    
    auto st = strategies.find(commands);
    if(st != strategies.end()){
        return *st;
    }else{
        // process errors
    }
}
    

int main() {
    auto policy_of_payment = get_user_option();
    std::unique_ptr<PaymentStrategy> strat = get_strategy(policy_of_payment);
    strat->pay(99.99); // credit pays...
}

您可以看到,策略模式是一个有效的方式处理。这个时候处理的种类本身被藏到了具体实现的子类中,现在,我们就可以实现编译时开销的策略分发——所有的条件分支被移动到了对象构造的决策上。这个时候就能方便的回退到上一个我们谈到的查表/映射策略了

状态检查和策略分发的if/else条件分支优化

笔者之前的单片机编程中被这个惹恼过,我相信不会有人喜欢编写裸的状态机的。毕竟思考状态的转化并且原子化的维护他总是一个麻烦,所以,一个经典的设计模式——状态机被提出仔细思考和抽象这类问题。

重构策略:状态机(State Machine)

基于状态的if-else很适合用状态机处理,他的好处是定义状态与允许的转换。在我们开始的时候就集中起来处理它。

#include <unordered_map>
#include <string>
#include <optional>
#include <iostream>

using State = std::string;
using Action = std::string;

int main() {
    std::unordered_map<State, std::unordered_map<Action, State>> transitions{
        {"draft",   {{"submit", "pending"}}},
        {"pending", {{"approve", "published"}, {"reject", "rejected"}}},
        {"rejected",{{"resubmit", "pending"}}}
    };

    State cur = "draft";
    Action act = "submit";
    auto ns = transitions[cur].find(act);
    if (ns != transitions[cur].end()) {
        cur = ns->second;
        std::cout << "new state: " << cur << '
';
    } else {
        std::cout << "invalid transition
";
    }
}

可用更成熟的状态机库(Boost MSM、Boost SML、yaksok等)实现复杂流程与可视化。


重构策略:规则引擎 / 决策表(Rules Engine / Decision Table)

​ 这个策略讨论的是基于线性的而非图的状态分发,我们把条件与结论以表格/规则的形式定义,规则引擎负责匹配与执行。适合业务规则频繁变化或由非程序员维护的场景。换而言之,我们把写死的cond和对应执行的驱动分离开来,变得可以被存储,这样就能化简到利用数据结构和循环回避条件分支判断。

#include <vector>
#include <functional>
#include <iostream>

struct Context { int age; std::string country; };

struct Rule {
    std::function<bool(const Context&)> cond;
    std::function<void(const Context&)> action;
};

int main() {
    std::vector<Rule> rules{
        {[](auto& c){ return c.age >= 18 && c.country=="US"; }, [](auto&){ std::cout<<"allow
"; }},
        {[](auto& c){ return c.age < 18; }, [](auto&){ std::cout<<"deny: underage
"; }}
    };

    Context ctx{20, "US"};
    for (auto& r : rules) if (r.cond(ctx)) r.action(ctx);
}

什么时候我们是要重构的

很简单,还记不记得笔者说三层以上的if/else才值得做?毕竟if/else简单处理流程中我们完全不需要搞如此复杂的注册机制。他们会显著的增加了开销。理性的办法是拿数据说话——我们的性能热点的确在这里,且修改的收益是完全可以接受的。

当然,还有一种是重构风险高且缺乏测试。这种情况下别贸然重构,必须想清楚这个地方的重构是值得的,您正确理解这段代码业务的。