详解C++元编程之Parser Combinator

引子

前不久在CppCon上看到一个Talk:[constexpr All the things](https://www.youtube.com/watch?v=PJwd4JLYJJY),这个演讲技术令我非常震惊,在编译期解析json字符串,进而提出了编译期构造正则表达式(编译期构建FSM),现场掌声一片,而背后依靠的是C++强大的constexpr特性,从而大大提高了编译期计算威力。

早在C++11的时候就有constexpr特性,那时候约束比较多,只能有一条return语句,能做的事情只有简单的递归实现一些数学、hash函数;而到了C++14的时候这个约束放开了,允许像普通函数那样,进而社区产生了一系列constexpr库;而在C++17,更加泛化了constexpr,允许`if constexpr`来代替元编程的SFINAE手法,STL库的一些算法支持constexpr,甚至连lambda都默认是constexpr的了;到C++20,更加难以想象,居然支持了constexpr new,STL的vector都是constexpr的了,若用constexpr allocator和constexpr destructor,那么就能统一所有constexpr容器了。

借助C++的constexpr能力,可以轻而易举的构造Parser Combinator,实现一个Parser也没那么繁杂了,对用户定义的字符串(User defined literal)释放了巨大的潜力,这也是本文的重点。

什么是Parser

Parser是一个解析器函数,输入一个字符串,输出解析后的类型值集合,函数签名如下:

Parser a:: String -> [(a, String)]

简单起见,这里我们考虑只输出零或一个类型值结果,而不是集合,那么签名如下:

Parser a:: String -> Maybe (a, String)

举个例子,一个数字Parser,解析输入字符串`"123456"`,输出结果为`Just (1, "23456")`,即得到了数字1和剩余字符串`"23456"`,从而可以供下一个Parser使用;若解析失败,输出`None`。

对应C++的函数签名,如下:

// Parser a :: String -> Maybe (a, String)
using ParserInput = std::string_view;
template <typename T>
using ParserResult = std::optional<std::pair<T, ParserInput>>;
template <typename T>
using Parser = auto(*)(ParserInput) -> ParserResult<T>;

这就是Parser的定义了。

根据定义可以实现几个最基本的Parser,例如匹配给定的字符:

constexpr auto makeCharParser(char c) {
    // CharParser :: Parser Char
    return [=](ParserInput s) -> ParserResult<char> {
        if (s.empty() || c != s[0]) return std::nullopt;
        return std::make_pair(s[0], ParserInput(s.begin() + 1, s.size() - 1));
    };
};

`makeCharParser`相当于一个工厂,给定字符`c`,创建匹配`c`的Parser。

匹配给定集合中的字符:

constexpr auto oneOf(std::string_view chars) {
    // OneOf :: Parser Char
    return [=](ParserInput s) -> ParserResult<char> {
        if (s.empty() || chars.find(s[0]) == std::string::npos) return std::nullopt;
        return std::make_pair(s[0], ParserInput(s.begin() + 1, s.size() - 1));
    };
}

什么是Parser Combinator

Parser是可组合的最小单元,Parser与Parser之间可以组合成任意复杂的Parser,而Parser Combinator就是一个高阶函数,输入一系列Parser,输出复合后的新Parser。

根据定义,可以实现一个Combinator组合两个Parser,同时根据两个Parser的结果计算出新的结果,从而得到新的Parser:

// combine :: Parser a -> Parser b -> (a -> b -> c) -> Parser c
template<typename P1, typename P2, typename F,
    typename R = std::invoke_result_t<F, Parser_t<P1>, Parser_t<P2>>>
constexpr auto combine(P1&& p1, P2&& p2, F&& f) {
    return [=](ParserInput s) -> ParserResult<R> {
        auto r1 = p1(s);
        if (!r1) return std::nullopt;
        auto r2 = p2(r1->second);
        if (!r2) return std::nullopt;
        return std::make_pair(f(r1->first, r2->first), r2->second);
    };
}

由于C++支持操作符重载,那么可以重载一个二元操作符来组合两个Parser,比如从两个Parser里取出其中一个Parser的结果产生新的Parser:

取左边Parser的结果:

// operator> :: Parser a -> Parser b -> Parser a
template<typename P1, typename P2>
constexpr auto operator>(P1&& p1, P2&& p2) {
    return combine(std::forward<P1>(p1),
                   std::forward<P2>(p2),
                   [](auto&& l, auto) { return l; });
};

取右边Parser的结果:

// operator< :: Parser a -> Parser b -> Parser b
template<typename P1, typename P2>
constexpr auto operator<(P1&& p1, P2&& p2) {
    return combine(std::forward<P1>(p1),
                   std::forward<P2>(p2),
                   [](auto, auto&& r) { return r; });
};

有时候需要对同一个Parser进行多次匹配,类似正则表达式的`*`操作,这个操作可以看做是`fold`,执行多次Parser直到匹配失败,每次结果传递给一个函数运算:

// foldL :: Parser a -> b -> (b -> a -> b) -> ParserInput -> ParserResult b
template<typename P, typename R, typename F>
constexpr auto foldL(P&& p, R acc, F&& f, ParserInput in) -> ParserResult<R> {
    while (true) {
        auto r = p(in);
        if (!r) return std::make_pair(acc, in);
        acc = f(acc, r->first);
        in = r->second;
    }
};

有了`fold`函数,那么可以很容易实现函数来匹配任意多次`many`,匹配至少一次`atLeast`:

// many :: Parser a -> Parser monostate
template<typename P>
constexpr auto many(P&& p) {
    return [p=std::forward<P>(p)](ParserInput s) -> ParserResult<std::monostate> {
        return detail::FoldL(p, std::monostate{}, [](auto acc, auto) { return acc; }, s);
    };
};
// atLeast :: Parser a -> b -> (b -> a -> b) -> Parser b
template<typename P, typename R, typename F>
constexpr auto atLeast(P&& p, R&& init, F&& f) {
    static_assert(std::is_same_v<std::invoke_result_t<F, R, Parser_t<P>>, R>,
            "type mismatch!");
    return [p=std::forward<P>(p),
           f=std::forward<F>(f),
           init=std::forward<R>(init)](ParserInput s) -> ParserResult<R> {
        auto r = p(s);
        if (!r) return std::nullopt;
        return detail::foldL(p, f(init, r->first), f, r->second);
    };
};

还有种操作是匹配零到一次,类似于正则表达式的`?`操作,这里我定义为`option`操作:

// option :: Parser a -> a -> Parser a
template<typename P, typename R = Parser_t<P>>
constexpr auto option(P&& p, R&& defaultV) {
    return [=](ParserInput s) -> ParserResult<R> {
        auto r = p(s);
        if (! r) return make_pair(defaultV, s);
        return r;
    };
};

有了以上基本操作,接下来看看如何运用。

实战

解析数值

项目中模板元编程比较多,而C++17之前模板Dependent type(非类型参数)不支持double,得C++20才支持double,临时方案就是用`template<char... C> struct NumWrapper {};`模拟double的类型,而需要获取其值的时候,就需要解析字符串了,这些工作应该在编译期确定。

首先是匹配符号`+/-`,若没有符号,则认为是`+`:

constexpr auto sign = Option(OneOf("+-"), '+');

其次是整数部分,也可能没有,若没有,则认为是0:

constexpr auto number = AtLeast(OneOf("1234567890"), 0l, [](long acc, char c) -> long {
    return acc * 10 + (c - '0');
});
constexpr auto integer = Option(number, 0l);

然后是小数点`.`,若没有小数点,为了不丢失精度,则返回一个`long`值。

constexpr auto point = MakeCharParser('.');
// integer
if (! (sign < integer < point)(in)) {
    return Combine(sign, integer, [](char sign, long number) -> R {
        return sign == '+' ? number : -number;
    })(in);
}

若有小数点,认为是浮点数,返回其`double`值。

// floating
constexpr auto decimal = point < Option(number, 0l);
constexpr auto value = Combine(integer, decimal, [](long integer, long decimal) -> double {
    double d = 0.0;
    while (decimal) {
        d = (d + (decimal % 10)) * 0.1;
        decimal /= 10;
    }
    return integer + d;
});
return Combine(sign, value, [](char sign, double d) -> R { return sign == '+' ? d : -d; })(in);
```
由于该Parser可能返回`long`或者`double`类型,所以可以统一成和类型`std::variant`:
```cpp
constexpr auto ParseNum() {
    using R = std::variant<double, long>;
    return [](ParserInput in) -> ParserResult<R> {
        // ...
    };
}

最后我们的`NumWrapper`实现如下,从而可以混入模板类型体系:

template<char... Cs>
constexpr std::array<char, sizeof...(Cs)> ToArr = {Cs...};
template<char ...Cs>
class NumberWrapper {
public:
    constexpr static auto numStr = ToArr<Cs...>;
    constexpr static auto res = ParseNum()(std::string_view(numStr.begin(), numStr.size()));
    static_assert(res.has_value() && res->second.empty(), "parse failed!");
public:
    constexpr static auto value = std::get<res->first.index()>(res->first); // long or double
}

如果仅仅是用于解析数字,那也杀鸡用牛刀了,因为在`Parser Combinator`之前的版本,我就是在一个普通的`constexpr`函数中完成解析的,代码很无趣,但现在我可能想回退代码了。

Json解析导读

这次的CppCon主题是编译期解析`json`字符串,当然直接用`string_view`承载字符串即可。然后构造一些constexpr容器,例如固定长度的constexpr vector,由于是17年的talk了,在还不支持constexpr new的情况下,只能这么做。有了constexpr vector,进而可以构造map容器,也是很简单的pair vector集合。

进而提出Parser Combinator,解析字符串,`fmap`到json数据结构中。

最初实现的时候,json数据结构也是一个大的`template<size_t Depth> struct Json_Value;`模板承载,导致只能指定最大递归层数,那就不够实用了。然后talker想了个很巧妙的办法去掉层数约束,就是先递归`sizes()`扫描一遍,计算出所有值个数,这样就能确定需要多少个`Value`容器来存储,其次计算出字符串长度,由于`UTF8`、转义字符串的影响,最终要解析的长度其实是可能小于输入长度的。有了确定空间后,进行第二遍递归`value_recur<NumObjects, StringSize>::value_parser()`扫描,每次解析完整值时候填一下`Value`数据结构。而由于数组和对象类似,可能嵌套,这时候进行第三遍递归`extent_recur<>::value_parser()`扫描,做一次宽度优先搜索,确定最外层的元素个数,从而依次解析填值。

以上就是详解C++元编程之Parser Combinator的详细内容,更多关于C++元编程之Parser Combinator的资料请关注我们其它相关文章!

(0)

相关推荐

  • C++11模板元编程-std::enable_if示例详解

    C++11中引入了std::enable_if函数,函数原型如下: template< bool B, class T = void > struct enable_if; 可能的函数实现: template<bool B, class T = void> struct enable_if {}; template<class T> struct enable_if<true, T> { typedef T type; }; 由上可知,只有当第一个模板参数为

  • c++ 内联函数和普通函数的区别

    前言 内联函数是c++为了提高程序的运行速度做的改进,它与普通函数区别在于: 编译器如何将它们组合到程序中.所以我们需要深入到程序内部. 我们的最终的可执行程序由 一组机器指令组成.程序运行时,计算机逐步执行指令. Ⅰ.常规函数 常规函数调用时会使程序跳到另一个地址(函数的地址),并且在函数结束时返回. 执行函数调用指令,立即存储该指令的地址,并将函数参数保存到的堆栈. 跳到函数起点的内存单元,执行函数代码(将返回值保存到寄存器中. 跳回被保存指令的地址处. 这一过程和系统中的中断很类似.来回跳

  • C++模板元编程实现选择排序

    前言 模板在C++一直是比较神秘的存在. STL 和 Boost 中都有大量运用模板,但是对于普通的程序员来说,模板仅限于使用.在一般的编程中,很少会有需要自己定义模板的情况.但是作为一个有理想的程序员,模板是一个绕不过去的坎.由于C++标准的不断改进,模板的能力越来越强,使用范围也越来越广. 在C++11中,模板增加了 constexpr ,可变模板参数,回返类型后置的函数声明扩展了模板的能力:增加了外部模板加快了模板的编译速度:模板参数的缺省值,角括号和模板别名使模板的定义和使用变得更加的简

  • 浅谈C++模板元编程

    所谓元编程就是编写直接生成或操纵程序的程序,C++ 模板给 C++ 语言提供了元编程的能力,模板使 C++ 编程变得异常灵活,能实现很多高级动态语言才有的特性(语法上可能比较丑陋,一些历史原因见下文).模板元编程的根在模板.模板的使命很简单:为自动代码生成提供方便.提高程序员生产率的一个非常有效的方法就是"代码复用",而面向对象很重要的一个贡献就是通过内部紧耦合和外部松耦合将"思想"转化成一个一个容易复用的"概念".但是面向对象提供的工具箱里面所

  • C++ 虚函数表图文解析

    一.前言 一直以来,对虚函数的理解仅仅是,在父类中定义虚函数,子类中可以重写该虚函数,并且父类指针可以指向子类对象,调用子类的虚函数(多态).在读研阶段经历的几个项目中,自己所写的类中并没有用到虚函数,对虚函数这个东西的强大之处并没有太多体会.最近,学了设计模式中的简单工厂模式,对多态有了具体的认识.于是,补了补多态.虚函数.虚函数表相关的知识,参考相关博客,加上自己的理解,整理了这篇博文. 二.含有虚函数类的内存模型 以下面的类为例(32位平台下): class Father { public

  • C++多线程实现TCP服务器端同时和多个客户端通信

    通讯建立后首先由服务器端发送消息,客户端接收消息:接着客户端发送消息,服务器端接收消息,实现交互发送消息. 服务器同时可以和多个客户端建立连接,进行交互: 在某次交互中,服务器端或某客户端有一方发送"end"即终止服务器与其的通信:服务器还可以继续接收其他客户端的请求,与其他客户端通信. 服务器端 #include <WinSock2.h> #include <WS2tcpip.h> #include <iostream> using namespa

  • C++中NULL与nullptr的区别对比

    前言 在编写C程序的时候只看到过NULL,而在C++的编程中,我们可以看到NULL和nullptr两种关键字,其实nullptr是C++11版本中新加入的,它的出现是为了解决NULL表示空指针在C++中具有二义性的问题,为了弄明白这个问题,我查找了一些资料,总结如下. 一.C程序中的NULL 在C语言中,NULL通常被定义为:#define NULL ((void *)0) 所以说NULL实际上是一个空指针,如果在C语言中写入以下代码,编译是没有问题的,因为在C语言中把空指针赋给int和char

  • C++中的多态详谈

    1. 多态概念 1.1 概念 多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态. 举个栗子:比如买票,当普通人买票时,是全价买票:学生买票时,是半价买票:军人买票时是优先买票.同一个事情针对不同的人或情况有不同的结果或形态. 2. 多态的定义及实现 2.1 多态的构成条件 多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为.比如Student继承了Person. Person对象买票全价,Student对象买票半价. 注意:那么在继

  • 详解C++元编程之Parser Combinator

    引子 前不久在CppCon上看到一个Talk:[constexpr All the things](https://www.youtube.com/watch?v=PJwd4JLYJJY),这个演讲技术令我非常震惊,在编译期解析json字符串,进而提出了编译期构造正则表达式(编译期构建FSM),现场掌声一片,而背后依靠的是C++强大的constexpr特性,从而大大提高了编译期计算威力. 早在C++11的时候就有constexpr特性,那时候约束比较多,只能有一条return语句,能做的事情只有

  • 详解Python GUI编程之PyQt5入门到实战

    1. PyQt5基础 1.1 GUI编程学什么 大致了解你所选择的GUI库 基本的程序的结构:使用这个GUI库来运行你的GUI程序 各种控件的特性和如何使用 控件的样式 资源的加载 控件的布局 事件和信号 动画特效 界面跳转 设计工具的使用 1.2 PyQT是什么 QT是跨平台C++库的集合,它实现高级API来访问现代桌面和移动系统的许多方面.这些服务包括定位和定位服务.多媒体.NFC和蓝牙连接.基于Chromium的web浏览器以及传统的UI开发.PyQt5是Qt v5的一组完整的Python

  • 详解python异步编程之asyncio(百万并发)

    前言:python由于GIL(全局锁)的存在,不能发挥多核的优势,其性能一直饱受诟病.然而在IO密集型的网络编程里,异步处理比同步处理能提升成百上千倍的效率,弥补了python性能方面的短板,如最新的微服务框架japronto,resquests per second可达百万级. python还有一个优势是库(第三方库)极为丰富,运用十分方便.asyncio是python3.4版本引入到标准库,python2x没有加这个库,毕竟python3x才是未来啊,哈哈!python3.5又加入了asyn

  • 详解Java并发编程之volatile关键字

    目录 1.volatile是什么? 2.并发编程的三大特性 3.什么是指令重排序? 4.volatile有什么作用? 5.volatile可以保证原子性? 6.volatile 和 synchronized对比 总结 1.volatile是什么? 首先简单说一下,volatile是什么?volatile是Java中的一个关键字,也是一种同步机制.volatile为了保证变量的可见性,通过volatile修饰的变量具有共享性.修改了volatile修饰的变量,其它线程是可以读取到最新的值的 2.并

  • 详解C语言编程之thread多线程

    目录 线程创建与结束 线程的创建方式: 线程的结束方式: join() detach() 互斥锁 <mutex> 头文件介绍 std::mutex 介绍 std::lock_guard std::unique_lock 示例: 原子变量 线程同步通信 线程死锁 死锁概述 死锁产生的条件 示例: 总结 线程创建与结束 C++11 新标准中引入了四个头文件来支持多线程编程,他们分别是<atomic> ,<thread>,<mutex>,<condition

  • 详解JUC并发编程之锁

    目录 1.自旋锁和自适应锁 2.轻量级锁和重量级锁 轻量级锁加锁过程 轻量级锁解锁过程 3.偏向锁 4.可重入锁和不可重入锁 5.悲观锁和乐观锁 6.公平锁和非公平锁 7.共享锁和独占锁 8.可中断锁和不可中断锁 总结: 当多个线程访问一个对象时,如果不用考虑这些线程在运行环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的.但是现实并不是这样子的,所以JVM实现了锁机制,今天就叭叭叭JAVA中各种

  • 详解SpringBoot初始教程之Tomcat、Https配置以及Jetty优化

    1.介绍 在SpringBoot的Web项目中,默认采用的是内置Tomcat,当然也可以配置支持内置的jetty,内置有什么好处呢? 1. 方便微服务部署. 2. 方便项目启动,不需要下载Tomcat或者Jetty 在目前的公司已经把内置的Jetty部署到了线上项目中,目前来说并无太大问题,内置就算有一些性能损失,但是通过部署多台机器, 其实也能够很轻松的解决这样的问题,内置容器之后其实是方便部署和迁移的. 1.1 优化策略 针对目前的容器优化,目前来说没有太多地方,需要考虑如下几个点 线程数

  • 详解iOS多线程之2.NSThread的加锁@synchronized

    那什么时候需要加锁呢,就是当多条线程同时操作一个变量时,就需要加锁了. 上代码 声明变量 @interface ViewController () @property (strong, nonatomic)NSThread *thread1; @property (strong, nonatomic)NSThread *thread2; @property (strong, nonatomic)NSThread *thread3; @property (assign, nonatomic)int

  • ruby元编程之method_missing的一个使用细节

    我们知道顶级域,定义域的self是啥? 复制代码 代码如下: puts self    #main puts self.class #Object 我们知道当一个方法被调用的时候,如果没有对象接受,默认就是self,如: 复制代码 代码如下: def tell_me_who     puts self end tell_me_who  #main 方法调用是这样的步骤,先查找当前对象的所在类的实例方法存在方法与否,如果存在,调用方法,如果不存在则查看superclass,直到 BasicObje

  • 正则 js分转元带千分符号详解

    可以通过缩放来进行分到元的转换,同时使用正则对处理后的数字进行千分位格式化 方法1:(不丢失精度) function Fen2Yuan( num ) { if ( typeof num !== "number" || isNaN( num ) ) return null; return ( num / 100 ).toFixed( 2 ); } 方法2: var num = 370825 num=num*0.01;//分到元 num+='';//转成字符串 var reg=num.in

随机推荐