美文网首页
用C++来实现函数的组合

用C++来实现函数的组合

作者: 杯水相伴 | 来源:发表于2018-07-04 00:13 被阅读0次

    曾经徜徉于高等数学海洋中的人,对于下列的表达式应该不会陌生:

    fg(x)

    它是一种函数嵌套的简写,频繁出现于诸多复杂公式以及各色偏导数算式中,它有着如下等价意义:
    fg(x)=f(g(x))
    当然,嵌套的次数没有限制,于是可能会出现下面这样令人望而生畏的写法:
    fgffh(x)
    这里我们的问题在于:能否用C++实现这样的功能呢?
    简而言之,我们想要的是这样的一个函数:

    Function  comb(Function a, Function b,…);
    

    这个函数要求输入2个或以上的函数,并返回他们的嵌套组合函数。这里的奇特之处在于这个函数的参数与返回值均是函数,如果可行,我们可能会希望这么使用它:

    extern int f(int x);
    extern int g(int x);
    fg = comb(f, g); 
    

    于是fg(x) 等价于f(g(x))。
    那么,要怎么去做呢?

    一个二元组合方案

    一开始就试图写出一个不定参数的方案难免有些过于跳跃了,我们可以先从组合两个函数入手。而如果读过《C++沉思录》,那么想必会对其21章——函数对象的内容印象深刻,作者就这个问题给出了一种设计方案,基本实现了组合两个函数的构想(有兴趣的同学推荐阅读这本书)。这里用了基本实现这个词的原因在于完全按照上述comb的写法是不可能的,在类型推导的过程中,函数的输入参数与输出参数类型是无论如何也无法被自动推导而出的,因此作者最终给出的方案是这样的:

    fg = comb<X, Y>(f, g);
    

    两个模板参数分别指示了组合后函数的输入与输出类型。 在试图构建一个多元组合函数前,让我们先来研究一下《C++沉思录》中二元组合的设计过程:

    直觉带来的问题

    在某些语言中,函数可以被视为“一等公民”,并被作为参数传递(比如Lisp),通过这个特性,函数嵌套可以被轻易地实现,问题在于C++是否具有这个特性。 用C++去实现这个函数的直觉反应是这么来写:

    using p = int(*)(int);
    p comb(p f, p ,g)
    {
        return int result(int x) {f(g(x));};
    }
    

    但是这是无效的,遗憾之处在于C++并不会把函数作为一等公民,C++的学习经历会告诉我们:嵌套定义是不被支持的,于是容易想到这样的曲线救国:

    int fg(int x)
    {
        f(g(x));
    }
    
    p comb(p f, p, g)
    {
        return &fg;
    }
    

    结果依旧不容乐观,此时的问题在于fg的域中没有f与g的定义,即fg无法“认出”f和g,于是这个方案依旧以失败告终。
    到这里问题似乎还是比较明了的,嵌套定义的不支持成为了最大的障碍。实际上,即使C++支持嵌套定义,上述做法也无法成功。这里我直接引述《C++沉思录》上的解释:

    可惜的是:答案是“实际上不会成功”。为了了解原因,我们稍微修改了一下compose函数:
    //这段代码还是不起作用
    int (compose(int f(int), int g(int)))(int x)
    {
        int (
    fp)(int) = f;
        int (*gp)(int) = g;
        int result(int n) {return fp(gp(n));}
        return result;
    }
    其中所做的改变是要将f和g的地址复制到两个局部变量fp和gp中去。现在,假设我们调用compose,它返回一个指向result的指针。因为fp和gp是compose的局部变量,所以一旦compose返回它们就消失了。如果我们现在调用result,他将试图使用这些局部变量,但这些变量已经被删除了。结果很可能导致程序运行崩溃。

    简而言之,除了嵌套定义,对函数指针的内存管理也构成了障碍,那么如何解决呢?
    这里的问题在于“创建”了新的函数,每当我们需要一个新的东西,一个新的类往往是解决之道,而关于函数,C++提供了函数对象这样一种特殊的类。
    原书的作者给出了三个阶段不同层次上的解决方案(后文中的相关类结构为了便于理解,做了一些简化):

    单个问题的应对

    一个简单的函数对象就可以直观地应对上面的需求:

    class PtrFunc
    {
    public:
        using p = int(*)(int); //一个类型别名
        explicit PtrFunc(p f, p g) 
            : _f(f),
              _g(g) {}
    
        int operator() (int x)
        {
            return _f(_g(x));
        }
    
    private:
        p _f;  //用于存放函数指针的成员
        p _g;
    };
    
    PtrFunc ptrcomb(PtrFunc::p f, PtrFunc::p g) //一个组合函数
    {
        return PtrFunc(f, g);
    }
    
    //如下调用
    fg = ptrcomb(f, g);
    fg(3) //等价于f(g(3))
    

    这个函数对象没有什么独特之处,无需多作解释。然而问题在于,函数指针限制了参数类型,如果目标组合函数的输入与输出类型出现了变化,将不得不面临重写的问题。而且,上述的方案限定了仅能将函数指针作为参数,而这完全可以被进一步扩展,使得函数对象(或者所有行为像函数的东西)也可以作为待组合的函数,解决的途径依赖于C++的另一项法宝——模板。

    一类问题的应对

    模板提供了一套灵活的解决方案,通过将要组合的函数类型定义为模板,这个问题变得很灵活,参考模板的用法,得到如下的方案:

    //所有类型均为模板
    template<typename X, typename Y, typename F, typename G> 
    class SimpFunc
    {
    public:
        explicit SimpFunc(F f, G g)
            : _f(f),
              _g(g) {}
    
        Y operator() (X x)
        {
            return _f(_g(x));
        }
    
    private: 
        F _f; //这里的成员类别变成了模板
        G _g;
    };
    
    template<typename X, typename Y, typename F, typename G>
    SimpFunc<X, Y, F, G> simpcomb(F f, G g) //组合函数变得复杂了
    {
        return SimpFunc<X, Y, F, G>(f, g);
    }
    //一个使用
    fg = simpcomb<int, int, int(*)int, int(*)int>(f, g);
    

    这个方案能够经受住一些考验,然而正如作者所言——实在不值得欣赏,一个简单的问题便是如果试图组合fg和f,将得到这样一个令人望而生畏的模板参数:

    <int, int, SimpFunc<int, int, int(*), int(*)>, int(*)int>
    

    这个写法倒成了一堂生动地模板参数辨识课。所幸之处在于,它可以被简化。

    第三次的尝试

    我们期望达到的最终解决方案隐藏了函数的类型,为了达到这个目的,使用了继承与组合:

    //一个包含了参数类型的抽象类
    template<typename X, typename Y>
    class Func_base
    {
    public:
        virtual Y operator()(X) = 0;
    };
    
    //派生出的函数对象,具有完整类型信息
    template<typename X, typename Y, typename F, typename G>
    class Func : public Func_base<X, Y>
    {
    public:
        explicit Func(F f, G g)
            : _f(f),
              _g(g) {}
    
    
        Y operator() (X x)
        {
            return _f(_g(x));
        }
    
    private:
        F _f;
        G _g;
    };
    
    //一个“外壳”,在运算符中调用包含的函数对象,注意其模板不包含函数的类型
    template<typename X, typename Y>
    class Functor
    {
    public:
    ////关键在于构造函数是一个函数模板,能够对函数类型作推导
        template<typename F, typename G> 
        explicit Functor(F f, G g)
            : _p(new Func<X, Y, F, G>(f, g)) {}
    
        Y operator() (X x)
        {
            return (*_p)(x);
        }
    
        ~Functor() {delete _p;}
    
    private:
        Func_base<X, Y>* _p; //基类指针,实际指向了组合函数的类型
    };
    //一个组合函数
    template<typename X, typename Y, typename F, typename G>
    Functor<X, Y> comb(F f, G g)
    {
        return Functor<X, Y>(f, g);
    }
    
    //在使用时省略了函数类型,它们会被编译器推导而出
    fg = comb<int, int>(f, g);
    
    

    不得不承认,这个看似轻松的需求所蕴含的构思远比我想象中要多,这个方案有如下几个要点:
    通过继承提供了参数类型和函数类型的“分离”,使得模板参数获取能够分两步完成
    以一个“外壳”来调用那个“真正的”函数对象(严格来讲,这个外壳也是个函数对象,重点在于其operator()的功能差异)
    本质两次引入了“中间层”来应对变化的问题,通过引入中间层来将多类问题统一处理是一类经典的分析模板(这种观念也被作者多次提到)。
    对于这个问题的更多讨论,有兴趣的同学可以深入阅读《C++沉思录》第21章——函数对象。

    多元组合——一个失败的方案

    我们现在已经具备了解决二元组合的方案,剩下的难题在于如何将其扩展为多个函数,一种容易想到的手段是使用C语言提供的不定参数机制,再结合vector容器的使用,这样我们可以将所有的函数放入vector中,在运算时逐个进行调用。下面是一个初步的方案:

    //省略Func_base
    template<typename X, typename Y, typename F>
    class VecFunc : public Func_base<X, Y>
    {
    public:
        using p = int(*)(int);
        explicit VecFunc(std::vector<F> fs) //传入一个装函数的vector
            :_Fs(fs)    {    }
    
        Y operator() (X x)
        {
            for (int i = 0; i < _Fs.size(); ++ i) //顺次调用,注意到输入与输出被限制
            {
                x = _Fs[i](x);
            }
            return x;
        }
    private:
        std::vector<F> _Fs;
    };
    
    template<typename X, typename Y>
    class VecFunctor
    {
    public:
        template<typename F>
        explicit VecFunctor(F f, ...)
        {
            va_list ap;   //不定参数的处理媒介
            va_start(ap, f); //ap指向f之后的参数
            std::vector<F> fs;
            fs.push_back(f);
            F temp = va_arg(ap, F); //胺类型返回参数,并移动ap
            while (temp != 0)
            {
                fs.push_back(temp);
                temp = va_arg(ap, F);
            }
            va_end(ap);
            _p = new VecFunc<X,Y,F>(fs); //构造函数对象
        }
        Y operator() (X x)
        {
            return (*_p)(x);
        }
    
        ~VecFunctor() {delete _p;}
    
    private:
        Func_base<X, Y>* _p;
    };
    
    
    VecFunctor<int, int> fffg(f, f, f, g, 0); //因为这个方案是失败的,所以没有写一个comb函数
    

    这个方案主要有两个问题,第一个源于C不定参数机制自带的缺陷:这里必须强制性的依靠一个依赖变量来结束参数(这里的方法是用一个零来结尾,也可以通过参数来指定参数的个数)。这在美观上打了折扣。
    第二个问题则比较致命:注意到由于使用了vector,函数的类型被限制为了一种,即在组合函数时,函数类型或者全为函数指针,或者全为函数对象,否则vector在装入时会引发一个类型错误。同时输入与输出的类型也被拉成了一个东西(见运算符中的循环)。同样的,由于类型的原因,这个组合之后的产物也变得只能与自己的同类组合,而无法再与函数指针或函数对象组合(除非使用之前的二元组合函数,但这样又回到了原点)。
    我们为了类型上的灵活性引入了模板,而如今为了数目上的灵活性却又牺牲了类型,显然这不是一个成功的方案。

    变参模板——优雅解决之道

    C++11提供了一个重要的特性——变参模板。其出色的功能和诡异的写法给我留下了深刻的印象,关于变参模板,下面是一个简单的例子:

    //递归终止
    void print()
    {
       cout << "empty" << endl;
    }
    //一般式
    template <class T, class ...Args>
    void print(T head, Args... rest)
    {
       cout << "parameter " << head << endl;
       print(rest...);
    }
    

    可以看到,变参模板在写法上与不定参数异曲同工,但前者通过递归的方式解决了不定参数写法上的弊端,可以利用变参模板重写构造函数:

    explicit MFunc(F1 f1, Fs... fs); //类名直接用了改后的名字
    

    然而如果继续使用vector,仍无法解决第二个问题,所幸C++11提供了另一个法宝;tuple。
    Tuple是一个很特殊的工具,可以抽象地将其视为“变参pair”(考虑到其与变参模板千丝万缕的联系,我认为适合如此类比) 。一个简单用法如下:

    tuple<int,double,string> testtuple(1,2.0,"test");//创建
    auto str=get<0>(testtuple);//访问第一个元素,注意从0开始,
    auto int_v=get<1>(testtuple);//访问第二个元素。
    get<2>(testtuple)=3.0;//更改第三个元素的值。
    

    在这里我并非直接用到了tuple,而是借鉴了其实现原理,tuple的一种实现(之所以说一种,是因为通过类聚合可以达到同样的效果)是这样的:

    //一般化
    template<class _This,
    class... _Rest>
    class tuple<_This, _Rest...>
     : private tuple<_Rest...>
    {
     // 内容
    }
    //终止的偏特化
    template<>
    class tuple<>
    {
    //内容  
    };
    

    这种看起来继承了自己的行为初看让人费解,然而这里需要指出:类模板由于其模板参数的不同应当被视为不同的类型,把之前的tuple继承体系展开便一目了然:

    tuple<int, double, string> : tuple<double, string>;
    tuple<double,string> : tuple<string>;
    tuple<string> : tuple<>;
    

    看到了这样的结构,我们的最终方案也便有迹可循了,要点依旧是“看起来继承了自己”,写法上是这样的:

    //省略Func_base
    template<typename X, typename Y, typename F1, typename... Fs> //原型
    class MFunc : public MFunc<X, Y, Fs...>
    {
    public:
        explicit MFunc(F1 f1, Fs... fs)  //子类
            : MFunc<X, Y, Fs...>(fs...), //基类
              _f(f1)
               {}
    
         Y operator() (X x)
         {
             return _f(MFunc<X, Y, Fs...>::operator ()(x));
         }
    
    private:
        F1 _f;
    };
    
    template<typename X, typename Y, typename F1, typename F2> //二元偏特化
    class MFunc<X, Y, F1, F2> : public Func_base<X, Y>
    {
    public:
        explicit MFunc(F1 f1, F2 f2)
            : _f1(f1),
              _f2(f2) {}
        Y operator() (X x)
        {
            return _f1(_f2(x));
        }
    
    private:
        F1 _f1;
        F2 _f2;
    };
    
    
    template<typename X, typename Y>
    class MFunctor
    {
    public:
        template<typename F1, typename... Fs>
        explicit MFunctor(F1 f1, Fs... fs)
            : _p(new MFunc<X, Y, F1, Fs...>(f1, fs...)) {} //与之前一致的构造风格
    
        Y operator() (X x)
        {
            return (*_p)(x);
        }
    
        ~MFunctor() {delete _p;}
    
    private:
        Func_base<X, Y>* _p;
    };
    
    template<typename X, typename Y, typename F1, typename... Fs>
    MFunctor<X, Y> comb(F1 f1, Fs... fs)
    {
        return MFunctor<X, Y>(f1, fs...);
    }
    
    //调用
    ffffg<int, int> = comb<int, int>(f, f, f, f, g);
    fgffffg<int, int> = comb<int, int>(f, g, ffffg); //二次利用
    

    至此,我们算是完成了一个比较令人满意的设计。

    写在最后,换一个角度的审视

    最后,让我们重新审视一开始的问题:C++无法直接实现函数嵌套,源于C++的函数不能作为“一等公民”,而C++11的另一个新特性:lambda表达式一定程度上改变了这个局面,结合lambda表达式,我们有了另一种解决手段:

    auto fg = [f, g](int x)->int {return f(g(x));};
    
    

    遗憾的是,这个写法仍然不具有什么欣赏性,然而仅用了一行就完成了一系列曲折的工作,也算是另一种意义上的高效了。
    为了能够更优雅,我仍然试图写出一个用于组合函数的函数:

    template<typename X, typename Y, typename T, typename F, typename G>
    T comb(F f, G g)
    {
        return [f, g](X x) -> Y {return f(g(x));};
    }
    

    然而这个写法仍然不为编译器所认可,难题依旧在于类型推导(返回类型T无法被推导),lambda的问题在于其本身的类型也是不太容易写出的,显然这里的要求对编译器而言也过于苛刻了。

    C++存在着一些缺陷,然而其也提供了五花八门的特性,如何优雅地利用其特性来发挥C++的威力,始终是一项值得思考的课题。

    最后,所有相关的代码可以在这里看到:
    ·CombineFunction

    相关文章

      网友评论

          本文标题:用C++来实现函数的组合

          本文链接:https://www.haomeiwen.com/subject/jxzjuftx.html