在C ++中的懒惰评估

时间:2009-01-05 19:40:19

标签: c++ lazy-evaluation

C ++没有对延迟评估的原生支持(正如Haskell所做的那样)。

我想知道是否可以以合理的方式在C ++中实现延迟评估。如果是的话,你会怎么做?

编辑:我喜欢Konrad Rudolph的回答。

我想知道是否有可能以更通用的方式实现它,例如通过使用一个基本上适用于矩阵的参数化类,就像matrix_add对矩阵一样工作。

对T的任何操作都会返回lazy。唯一的问题是将参数和操作代码存储在惰性本身中。有谁能看到如何改善这个?

12 个答案:

答案 0 :(得分:84)

  

我想知道是否可以以合理的方式在C ++中实现延迟评估。如果是的话,你会怎么做?

是的,这是可能的并且经常完成,例如用于矩阵计算。促进这一点的主要机制是运算符重载。考虑矩阵加法的情况。函数的签名通常如下所示:

matrix operator +(matrix const& a, matrix const& b);

现在,为了使这个函数变得懒惰,它足以返回代理而不是实际结果:

struct matrix_add;

matrix_add operator +(matrix const& a, matrix const& b) {
    return matrix_add(a, b);
}

现在需要做的就是编写这个代理:

struct matrix_add {
    matrix_add(matrix const& a, matrix const& b) : a(a), b(b) { }

    operator matrix() const {
        matrix result;
        // Do the addition.
        return result;
    }
private:
    matrix const& a, b;
};

神奇之处在于方法operator matrix(),它是从matrix_add到普通matrix的隐式转换运算符。这样,您可以链接多个操作(当然,通过提供适当的重载)。只有在将最终结果分配给matrix实例时才会进行评估。

编辑我本来应该更明确。实际上,代码没有任何意义,因为尽管评估是懒散的,但它仍然发生在同一个表达式中。特别是,除非matrix_add结构更改为允许链接添加,否则另一个添加将评估此代码。 C ++ 0x通过允许可变参数模板(即可变长度的模板列表)极大地促进了这一点。

然而,这个代码实际上具有真正直接好处的一个非常简单的例子如下:

int value = (A + B)(2, 3);

这里假设AB是二维矩阵,并且以Fortran表示法进行解除引用,即上面计算一个元素矩阵和。添加整个矩阵当然是浪费。 matrix_add救援:

struct matrix_add {
    // … yadda, yadda, yadda …

    int operator ()(unsigned int x, unsigned int y) {
        // Calculate *just one* element:
        return a(x, y) + b(x, y);
    }
};

其他例子比比皆是。我记得不久前我已经实现了一些相关的东西。基本上,我必须实现一个应该遵循固定的预定义接口的字符串类。但是,我的特定字符串类处理的是实际上并未存储在内存中的大字符串。通常,用户只需使用函数infix从原始字符串访问小子串。我为我的字符串类型重载了这个函数,以返回一个代理,该代理保存对我的字符串的引用,以及所需的开始和结束位置。只有在实际使用此子字符串时,它才会查询C API以检索字符串的这一部分。

答案 1 :(得分:30)

Boost.Lambda非常好,但Boost.Proto 正好您正在寻找什么。它已经有所有 C ++运算符的重载,默认情况下在调用proto::eval()时执行它们的常用功能,但可以更改。

答案 2 :(得分:25)

Konrad已经解释过的内容可以进一步支持运算符的嵌套调用,所有这些都是懒惰执行的。在Konrad的例子中,他有一个表达式对象,它可以存储两个参数,正好是一个操作的两个操作数。问题是它只会懒惰地执行一个子表达式,这很好地解释了懒惰评估中的概念,简单来说,但并没有大幅提高性能。另一个示例也很好地展示了如何使用operator()仅使用该表达式对象添加一些元素。但是为了评估任意复杂的表达式,我们需要一些能够存储结构的机制。我们无法绕过模板来做到这一点。其名称为expression templates。这个想法是一个模板化的表达式对象可以递归地存储某个任意子表达式的结构,就像一棵树,其中操作是节点,操作数是子节点。对于我今天刚刚发现的非常的好解释(在我编写下面的代码后几天),请参阅here

template<typename Lhs, typename Rhs>
struct AddOp {
    Lhs const& lhs;
    Rhs const& rhs;

    AddOp(Lhs const& lhs, Rhs const& rhs):lhs(lhs), rhs(rhs) {
        // empty body
    }

    Lhs const& get_lhs() const { return lhs; }
    Rhs const& get_rhs() const { return rhs; }
};

这将存储任何添加操作,甚至是嵌套的操作,如下面的操作符+对于简单点类型的定义所示:

struct Point { int x, y; };

// add expression template with point at the right
template<typename Lhs, typename Rhs> AddOp<AddOp<Lhs, Rhs>, Point> 
operator+(AddOp<Lhs, Rhs> const& lhs, Point const& p) {
    return AddOp<AddOp<Lhs, Rhs>, Point>(lhs, p);
} 

// add expression template with point at the left
template<typename Lhs, typename Rhs> AddOp< Point, AddOp<Lhs, Rhs> > 
operator+(Point const& p, AddOp<Lhs, Rhs> const& rhs) {
    return AddOp< Point, AddOp<Lhs, Rhs> >(p, rhs);
}

// add two points, yield a expression template    
AddOp< Point, Point > 
operator+(Point const& lhs, Point const& rhs) {
    return AddOp<Point, Point>(lhs, rhs);
}

现在,如果你有

Point p1 = { 1, 2 }, p2 = { 3, 4 }, p3 = { 5, 6 };
p1 + (p2 + p3); // returns AddOp< Point, AddOp<Point, Point> >

您现在只需要重载operator =并为Point类型添加合适的构造函数并接受AddOp。将其定义更改为:

struct Point { 
    int x, y; 

    Point(int x = 0, int y = 0):x(x), y(y) { }

    template<typename Lhs, typename Rhs>
    Point(AddOp<Lhs, Rhs> const& op) {
        x = op.get_x();
        y = op.get_y();
    }

    template<typename Lhs, typename Rhs>
    Point& operator=(AddOp<Lhs, Rhs> const& op) {
        x = op.get_x();
        y = op.get_y();
        return *this;
    }

    int get_x() const { return x; }
    int get_y() const { return y; }
};

将相应的get_x和get_y作为成员函数添加到AddOp中:

int get_x() const {
    return lhs.get_x() + rhs.get_x();
}

int get_y() const {
    return lhs.get_y() + rhs.get_y();
}

请注意我们没有创建任何Point类型的临时工具。它可能是一个有许多领域的大矩阵。但在需要结果的时候,我们计算懒惰

答案 3 :(得分:10)

我没有任何内容可以添加到Konrad的帖子中,但您可以在真实世界的应用程序中查看Eigen的懒惰评估示例。这真是令人敬畏。

答案 4 :(得分:3)

我正在考虑实现一个使用std::function的模板类。课程应该或多或少看起来像这样:

template <typename Value>
class Lazy
{
public:
    Lazy(std::function<Value()> function) : _function(function), _evaluated(false) {}

    Value &operator*()  { Evaluate(); return  _value; }
    Value *operator->() { Evaluate(); return &_value; }

private:
    void Evaluate()
    {
        if (!_evaluated)
        {
            _value = _function();
            _evaluated = true;
        }
    }

    std::function<Value()> _function;
    Value _value;
    bool _evaluated;
};

例如用法:

class Noisy
{
public:
    Noisy(int i = 0) : _i(i)
    {
        std::cout << "Noisy(" << _i << ")"  << std::endl;
    }
    Noisy(const Noisy &that) : _i(that._i)
    {
        std::cout << "Noisy(const Noisy &)" << std::endl;
    }
    ~Noisy()
    {
        std::cout << "~Noisy(" << _i << ")" << std::endl;
    }

    void MakeNoise()
    {
        std::cout << "MakeNoise(" << _i << ")" << std::endl;
    }
private:
    int _i;
};  

int main()
{
    Lazy<Noisy> n = [] () { return Noisy(10); };

    std::cout << "about to make noise" << std::endl;

    n->MakeNoise();
    (*n).MakeNoise();
    auto &nn = *n;
    nn.MakeNoise();
}

上面的代码应该在控制台上产生以下消息:

Noisy(0)
about to make noise
Noisy(10)
~Noisy(10)
MakeNoise(10)
MakeNoise(10)
MakeNoise(10)
~Noisy(10)

请注意,在访问变量之前,不会调用构造函数print Noisy(10)

但这个课程远非完美。第一件事是Value的默认构造函数必须在成员初始化时调用(在这种情况下打印Noisy(0))。我们可以使用指针代替_value,但我不确定它是否会影响性能。

答案 5 :(得分:3)

约翰&#39;回答是有效的。但是当谈到更多的括号时,它并没有像希望的那样工作。这是一个例子。

Point p1 = { 1, 2 }, p2 = { 3, 4 }, p3 = { 5, 6 }, p4 = { 7, 8 };
(p1 + p2) + (p3+p4)// it works ,but not lazy enough

因为三个重载+运算符没有覆盖案例

AddOp<Llhs,Lrhs>+AddOp<Rlhs,Rrhs>

因此编译器必须将(p1 + p2)或(p3 + p4)转换为Point,这不够懒惰。当编译器决定转换哪个时,它会抱怨。因为没有一个比另一个好。 这是我的扩展:添加另一个重载的运算符+

    template <typename LLhs, typename LRhs, typename RLhs, typename RRhs>
AddOp<AddOp<LLhs, LRhs>, AddOp<RLhs, RRhs>> operator+(const AddOp<LLhs, LRhs> & leftOperandconst, const AddOp<RLhs, RRhs> & rightOperand)
{
    return  AddOp<AddOp<LLhs, LRhs>, AddOp<RLhs, RRhs>>(leftOperandconst, rightOperand);

}

现在,编译器可以正确处理上面的情况,而没有隐式转换,volia!

答案 6 :(得分:2)

一切皆有可能。

这完全取决于你的意思:

class X
{
     public: static X& getObjectA()
     {
          static X instanceA;

          return instanceA;
     }
};

这里我们有一个全局变量的影响,该变量在首次使用时被懒惰地评估。

如问题中新要求的那样 并且窃取了Konrad Rudolph的设计并扩展了它。

懒惰对象:

template<typename O,typename T1,typename T2>
struct Lazy
{
    Lazy(T1 const& l,T2 const& r)
        :lhs(l),rhs(r) {}

    typedef typename O::Result  Result;
    operator Result() const
    {
        O   op;
        return op(lhs,rhs);
    }
    private:
        T1 const&   lhs;
        T2 const&   rhs;
};

如何使用它:

namespace M
{
    class Matrix
    {
    };
    struct MatrixAdd
    {
        typedef Matrix  Result;
        Result operator()(Matrix const& lhs,Matrix const& rhs) const
        {
            Result  r;
            return r;
        }
    };
    struct MatrixSub
    {
        typedef Matrix  Result;
        Result operator()(Matrix const& lhs,Matrix const& rhs) const
        {
            Result  r;
            return r;
        }
    };
    template<typename T1,typename T2>
    Lazy<MatrixAdd,T1,T2> operator+(T1 const& lhs,T2 const& rhs)
    {
        return Lazy<MatrixAdd,T1,T2>(lhs,rhs);
    }
    template<typename T1,typename T2>
    Lazy<MatrixSub,T1,T2> operator-(T1 const& lhs,T2 const& rhs)
    {
        return Lazy<MatrixSub,T1,T2>(lhs,rhs);
    }
}

答案 7 :(得分:2)

C ++ 0x很好,所有....但对于我们这些生活在现在的人来说,你有Boost lambda库和Boost Phoenix。两者都旨在为C ++带来大量的函数式编程。

答案 8 :(得分:1)

因为它将在C++0x中由lambda表达式完成。

答案 9 :(得分:1)

在C ++ 11中,使用std :: shared_future可以实现类似于hiapay答案的懒惰评估。你仍然需要在lambdas中封装计算,但是记忆被处理:

std::shared_future<int> a = std::async(std::launch::deferred, [](){ return 1+1; });

以下是一个完整的例子:

#include <iostream>
#include <future>

#define LAZY(EXPR, ...) std::async(std::launch::deferred, [__VA_ARGS__](){ std::cout << "evaluating "#EXPR << std::endl; return EXPR; })

int main() {
    std::shared_future<int> f1 = LAZY(8);
    std::shared_future<int> f2 = LAZY(2);
    std::shared_future<int> f3 = LAZY(f1.get() * f2.get(), f1, f2);

    std::cout << "f3 = " << f3.get() << std::endl;
    std::cout << "f2 = " << f2.get() << std::endl;
    std::cout << "f1 = " << f1.get() << std::endl;
    return 0;
}

答案 10 :(得分:0)

让我们以Haskell作为我们的灵感-它是核心的惰性。 另外,让我们记住C#中的Linq如何以单子形式(枚举-这是这个词-对不起)使用枚举器。 最后,让我们牢记协程应该为程序员提供什么。即,计算步骤(例如,生产者消费者)之间的解耦。 让我们考虑一下协程与懒惰评估的关系。

以上所有内容似乎都有某种联系。

接下来,让我们尝试提取我们对“懒惰”的定义。

一种解释是:我们想在执行之前以一种可组合的方式陈述我们的计算。我们用来构成完整解决方案的某些部分可能会很好地利用巨大的(有时是无限的)数据源,而我们的完整计算也会产生有限或无限的结果。

让我们具体化一些代码。我们需要一个例子!在这里,我选择fizzbuzz的“问题”作为示例,只是因为有一些不错的,懒惰的解决方案。

在Haskell中,它看起来像这样:     

module FizzBuzz
( fb
)
where
fb n =
    fmap merge fizzBuzzAndNumbers
    where
        fizz = cycle ["","","fizz"]
        buzz = cycle ["","","","","buzz"]
        fizzBuzz = zipWith (++) fizz buzz
        fizzBuzzAndNumbers = zip [1..n] fizzBuzz
        merge (x,s) = if length s == 0 then show x else s

Haskell函数cycle通过简单地永久重复有限列表中的值,从有限列表中创建无限列表(当然是懒惰!)。以一种急切的编程风格,编写类似的东西会敲响警钟(内存溢出,无休止的循环!)。但是用懒惰的语言却不是这样。诀窍在于,不会立即计算惰性列表。也许永远不会。通常,仅根据后续代码的要求。

上方where块中的第三行再次造成了懒惰!通过使用单个两个元素组合无限列表fizzbuzz来组合列表,例如“将任一输入列表中的字符串元素连接为单个字符串”。同样,如果要立即对此进行评估,我们将不得不等待计算机资源耗尽。

在第四行中,我们使用无限懒惰列表[1..n]创建一个有限懒惰列表fizzbuzz的成员的元组。结果仍然很懒。

即使在我们的fb函数的主体中,也没有必要变得急切。整个函数返回一个带有解决方案的列表,该列表本身又是-lazy-的。您也可以将fb 50的结果视为一种计算,以后可以(部分)进行评估。或与其他内容结合使用,可以进行更大的(惰性)评估。

因此,为了开始使用我们的C ++版本的“ fizzbuzz”,我们需要考虑如何将部分计算步骤组合为更大的计算量的方法,每个绘图数据都根据需要来自先前的步骤。

您可以在a gist of mine中查看完整的故事。

以下是代码背后的基本思想:

借用C#和Linq,我们“发明”了一个有状态的通用类型Enumerator,该类型保存了
  -部分计算的当前值
  -部分计算的状态(因此我们可以产生后续值)
  -worker函数,该函数产生下一个状态,下一个值和一个布尔值,该布尔值说明是否有更多数据或枚举是否结束。

为了能够借助Enumerator<T,S>(点)的力量来构成.实例,该类还包含从Haskell类型类(例如Functor)借用的函数。和Applicative

枚举器的工作函数始终具有以下形式:S -> std::tuple<bool,S,T,其中S是表示状态的通用类型变量,而T是表示值的通用类型变量-结果计算步骤。

所有这些都已经在Enumerator类定义的第一行中显示了。

template <class T, class S>
class Enumerator
{
public:
    typedef typename S State_t;
    typedef typename T Value_t;
    typedef std::function<
        std::tuple<bool, State_t, Value_t>
        (const State_t&
            )
    > Worker_t;

    Enumerator(Worker_t worker, State_t s0)
        : m_worker(worker)
        , m_state(s0)
        , m_value{}
    {
    }
    // ...
};

因此,我们需要创建一个特定的枚举器实例,我们需要创建一个工作函数,具有初始状态并创建带有这两个参数的Enumerator实例。

下面是一个示例-函数range(first,last)创建一个有限范围的值。这对应于Haskell世界中的一个懒惰列表。

template <class T>
Enumerator<T, T> range(const T& first, const T& last)
{
    auto finiteRange =
        [first, last](const T& state)
    {
        T v = state;
        T s1 = (state < last) ? (state + 1) : state;
        bool active = state != s1;
        return std::make_tuple(active, s1, v);
    };
    return Enumerator<T,T>(finiteRange, first);
}

我们可以使用此功能,例如:auto r1 = range(size_t{1},10);-我们创建了一个包含10个元素的惰性列表!

现在,我们的“哇”体验所缺的一切,都是看我们如何构成枚举器。 回到Haskells cycle函数,这很酷。在我们的C ++世界中会如何?在这里:

template <class T, class S>
auto
cycle
( Enumerator<T, S> values
) -> Enumerator<T, S>
{
    auto eternally =
        [values](const S& state) -> std::tuple<bool, S, T>
    {
        auto[active, s1, v] = values.step(state);
        if (active)
        {
            return std::make_tuple(active, s1, v);
        }
        else
        {
            return std::make_tuple(true, values.state(), v);
        }
    };
    return Enumerator<T, S>(eternally, values.state());
}

它将枚举数作为输入并返回一个枚举数。本地(lambda)函数eternally会在每次输入枚举用完值和值时将其枚举重置为其初始值-我们有一个无限,重复的列表,作为参数:{{1} }而且我们已经可以毫不留情地编写我们的懒惰的“计算”了。

auto foo = cycle(range(size_t{1},3));是一个很好的例子,表明我们还可以从两个输入枚举器创建一个新的枚举器。所得的枚举器产生的值与两个输入枚举器中的任一个较小的值(具有2个元素的元组,每个输入枚举器一个)。我已经在zip内部实现了zip。这是它的样子:

class Enumerator

请注意,“合并”最终也将合并两个源的状态和两个源的值。

由于该帖子已经是TL; DR;对于许多人来说,这里...

摘要

是的,可以在C ++中实现惰性评估。在这里,我通过从haskell借用函数名称以及C#枚举器和Linq的范例来做到这一点。可能与pythons itertools类似,顺便说一句。我认为他们采用了类似的方法。

我的实现(请参见上面的gist链接)只是一个原型-而不是生产代码,顺便说一句。因此,从我这边没有任何保证。不过,它很好地用作演示代码,可以使您了解总体思路。

如果没有fizzbuz的最终C ++版本,这个答案是什么?在这里:

// member function of class Enumerator<S,T> 
template <class T1, class S1>
auto
zip
( Enumerator<T1, S1> other
) -> Enumerator<std::tuple<T, T1>, std::tuple<S, S1> >
{
    auto worker0 = this->m_worker;
    auto worker1 = other.worker();
    auto combine =
        [worker0,worker1](std::tuple<S, S1> state) ->
        std::tuple<bool, std::tuple<S, S1>, std::tuple<T, T1> >
    {
        auto[s0, s1] = state;
        auto[active0, newS0, v0] = worker0(s0);
        auto[active1, newS1, v1] = worker1(s1);
        return std::make_tuple
            ( active0 && active1
            , std::make_tuple(newS0, newS1)
            , std::make_tuple(v0, v1)
            );
    };
    return Enumerator<std::tuple<T, T1>, std::tuple<S, S1> >
        ( combine
        , std::make_tuple(m_state, other.state())
        );
}

然后...将点进一步带回家-这是fizzbuzz的一种变体,它向调用者返回“无限列表”:

std::string fizzbuzz(size_t n)
{
    typedef std::vector<std::string> SVec;
    // merge (x,s) = if length s == 0 then show x else s
    auto merge =
        [](const std::tuple<size_t, std::string> & value)
        -> std::string
    {
        auto[x, s] = value;
        if (s.length() > 0) return s; 
        else return std::to_string(x);
    };

    SVec fizzes{ "","","fizz" };
    SVec buzzes{ "","","","","buzz" };

    return
    range(size_t{ 1 }, n)
    .zip
        ( cycle(iterRange(fizzes.cbegin(), fizzes.cend()))
          .zipWith
            ( std::function(concatStrings)
            , cycle(iterRange(buzzes.cbegin(), buzzes.cend()))
            )
        )
    .map<std::string>(merge)
    .statefulFold<std::ostringstream&>
    (
        [](std::ostringstream& oss, const std::string& s) 
        {
            if (0 == oss.tellp())
            {
                oss << s;
            }
            else
            {
                oss << "," << s;
            }
        }
        , std::ostringstream()
    )
    .str();
}

这值得展示,因为您可以从中学习如何回避该函数的确切返回类型是什么(因为它仅取决于函数的实现,即代码如何结合枚举器)。 / p>

这也说明我们必须将向量typedef std::vector<std::string> SVec; static const SVec fizzes{ "","","fizz" }; static const SVec buzzes{ "","","","","buzz" }; auto fizzbuzzInfinite() -> decltype(auto) { // merge (x,s) = if length s == 0 then show x else s auto merge = [](const std::tuple<size_t, std::string> & value) -> std::string { auto[x, s] = value; if (s.length() > 0) return s; else return std::to_string(x); }; auto result = range(size_t{ 1 }) .zip (cycle(iterRange(fizzes.cbegin(), fizzes.cend())) .zipWith (std::function(concatStrings) , cycle(iterRange(buzzes.cbegin(), buzzes.cend())) ) ) .map<std::string>(merge) ; return result; } fizzes移动到函数范围之外,因此当最终在外部时,惰性机制会产生值,因此它们仍然存在。如果我们不这样做,buzzes代码将把迭代器存储到早已消失的向量中。

答案 11 :(得分:0)

使用一个非常简单的惰性评估定义,即直到需要时才评估该值,我想说的是可以通过使用指针和宏(用于语法糖)来实现这一点。

#include <stdatomic.h>

#define lazy(var_type) lazy_ ## var_type

#define def_lazy_type( var_type ) \
    typedef _Atomic var_type _atomic_ ## var_type; \
    typedef _atomic_ ## var_type * lazy(var_type);  //pointer to atomic type

#define def_lazy_variable(var_type, var_name ) \
    _atomic_ ## var_type _ ## var_name; \
    lazy_ ## var_type var_name = & _ ## var_name;

#define assign_lazy( var_name, val ) atomic_store( & _ ## var_name, val )
#define eval_lazy(var_name) atomic_load( &(*var_name) )

#include <stdio.h>

def_lazy_type(int)

void print_power2 ( lazy(int) i )
{
      printf( "%d\n", eval_lazy(i) * eval_lazy(i) );
}

typedef struct {
    int a;
} simple;

def_lazy_type(simple)

void print_simple ( lazy(simple) s )
{
    simple temp = eval_lazy(s);
    printf("%d\n", temp.a );
}


#define def_lazy_array1( var_type, nElements, var_name ) \
    _atomic_ ## var_type  _ ## var_name [ nElements ]; \
    lazy(var_type) var_name = _ ## var_name; 

int main ( )
{
    //declarations
    def_lazy_variable( int, X )
    def_lazy_variable( simple, Y)
    def_lazy_array1(int,10,Z)
    simple new_simple;

    //first the lazy int
    assign_lazy(X,111);
    print_power2(X);

    //second the lazy struct
    new_simple.a = 555;
    assign_lazy(Y,new_simple);
    print_simple ( Y );

    //third the array of lazy ints
    for(int i=0; i < 10; i++)
    {
        assign_lazy( Z[i], i );
    }

    for(int i=0; i < 10; i++)
    {
        int r = eval_lazy( &Z[i] ); //must pass with &
        printf("%d\n", r );
    }

    return 0;
}

您会注意到在函数print_power2中有一个名为eval_lazy的宏,它仅在实际需要时才取消引用指针以获取值。惰性类型是原子访问的,因此它是完全线程安全的。

相关问题