C++ Annotations Version 12.5.0 学习(7)

输入操作的类

当输入操作的类派生自 std::streambuf 时,应该为它们提供至少一个字符的输入缓冲区。这个单字符输入缓冲区允许使用成员函数 istream::putbackistream::ungetc。严格来说,在派生自 streambuf 的类中实现缓冲区并不是必要的。但强烈建议在这些类中使用缓冲区。它们的实现非常简单直接,并且这种类的适用性得到了极大提高。因此,我们所有派生自 streambuf 类的类都定义了至少一个字符的缓冲区。

使用单字符缓冲区

当从 streambuf 派生类(例如 IFdStreambuf)并使用单字符缓冲区时,至少需要重写其成员 streambuf::underflow,因为该成员最终会接收所有输入请求。成员 streambuf::setg 用于通知 streambuf 基类输入缓冲区的大小和位置,以便它能够相应地设置其输入缓冲区指针。这确保了 streambuf::ebackstreambuf::gptrstreambuf::egptr 返回正确的值。

IFdStreambuf 类的设计如下:

  • 其成员函数使用在文件描述符上操作的底层函数。因此,除了 streambuf 之外,编译器在编译其成员函数之前必须已经读取 <unistd.h> 头文件。

  • 与大多数为输入操作设计的类一样,该类也派生自 std::streambuf

  • 该类定义了两个数据成员,其中一个是固定大小的单字符缓冲区。这些数据成员定义为 protected 访问级别,以便派生类(例如,第 26.1.2.3 节中的类)可以访问它们。以下是该类的完整接口:

    class IFdStreambuf: public std::streambuf
    {
    protected:
        int     d_fd;
        char    d_buffer[1];
    public:
        IFdStreambuf(int fd);
    private:
        int underflow() override;
    };
    
  • 构造函数初始化缓冲区。然而,初始化将 gptr 的返回值设置为 egptr 的返回值,这意味着缓冲区为空,因此 underflow 会立即被调用以填充缓冲区:

    inline IFdStreambuf::IFdStreambuf(int fd)
    :
        d_fd(fd)
    {
        setg(d_buffer, d_buffer + 1, d_buffer + 1);
    }
    
  • 最后,underflow 被重写。通过从文件描述符读取数据来重新填充缓冲区。如果读取失败(无论出于何种原因),将返回 EOF。当然,更复杂的实现可以在这里更智能地处理。如果缓冲区可以重新填充,setg 将被调用以正确设置 streambuf 的缓冲区指针:

    inline int IFdStreambuf::underflow()
    {
        if (read(d_fd, d_buffer, 1) <= 0)
            return EOF;
    
        setg(d_buffer, d_buffer, d_buffer + 1);
        return static_cast<unsigned char>(*gptr());
    }
    
  • 以下是展示如何使用 IFdStreambufmain 函数:

    int main()
    {
        IFdStreambuf fds(STDIN_FILENO);
        istream      is(&fds);
    
        cout << is.rdbuf();
    }
    

使用 n 字符缓冲区

如果我们决定使用一个大小较大的缓冲区,复杂性会增加多少呢?其实并不复杂。以下的类允许我们指定缓冲区的大小,除此之外,它基本上与前一节中开发的 IFdStreambuf 类相同。为了让事情更有趣一点,在这里开发的 IFdNStreambuf 类中,还重写了成员 streambuf::xsgetn,以优化读取一系列字符的过程。另外,还提供了一个默认构造函数,可以与 open 成员函数配合使用,在文件描述符可用之前构造一个 istream 对象。在这种情况下,一旦描述符可用,可以使用 open 成员函数初始化对象的缓冲区。在第 26.2 节中,我们将遇到这样的情况。

为了节省空间,未检查各种调用的成功性。在“现实生活”中的实现中,这些检查当然不应省略。IFdNStreambuf 类具有以下特征:

  • 其成员函数使用在文件描述符上操作的底层函数。因此,除了 streambuf 之外,编译器在编译其成员函数之前必须已经读取 <unistd.h> 头文件。

  • 像往常一样,它派生自 std::streambuf

  • IFdStreambuf 类(第 26.1.2.1 节)一样,其数据成员是受保护的。由于缓冲区的大小是可配置的,因此该大小保存在专用数据成员 d_bufsize 中:

    class IFdNStreambuf: public std::streambuf
    {
    protected:
        int         d_fd = -1;
        size_t      d_bufsize = 0;
        char*       d_buffer = 0;
    public:
        IFdNStreambuf() = default;
        IFdNStreambuf(int fd, size_t bufsize = 1);
        ~IFdNStreambuf() override;
        void open(int fd, size_t bufsize = 1);
    private:
        int underflow() override;
        std::streamsize xsgetn(char *dest, std::streamsize n) override;
    };
    
  • 默认构造函数不分配缓冲区。它可以在文件描述符未知时用于构造对象。第二个构造函数只是将其参数传递给 openopen 然后会初始化对象,以便实际使用:

    inline IFdNStreambuf::IFdNStreambuf(int fd, size_t bufsize)
    {
        open(fd, bufsize);
    }
    
  • 一旦对象通过 open 初始化,其析构函数将删除对象的缓冲区并使用文件描述符关闭设备:

    IFdNStreambuf::~IFdNStreambuf()
    {
        if (d_bufsize)
        {
            close(d_fd);
            delete[] d_buffer;
        }
    }
    

尽管在上述实现中设备被关闭了,但这并不总是理想的。在某些情况下,打开的文件描述符已经可用,可能希望重复使用该描述符,每次使用一个新构造的 IFdNStreambuf 对象。读者可以尝试修改该类,使得设备可以选择性地关闭。这种方法在例如 Bobcat 库中被采用。

  • open 成员函数只是分配对象的缓冲区。假定调用程序已经打开了设备。一旦分配了缓冲区,就使用基类成员 setg 来确保 streambuf::ebackstreambuf::gptrstreambuf::egptr 返回正确的值:

    void IFdNStreambuf::open(int fd, size_t bufsize)
    {
        d_fd = fd;
        d_bufsize =  bufsize == 0 ? 1 : bufsize;
        delete[] d_buffer;
        d_buffer = new char[d_bufsize];
        setg(d_buffer, d_buffer + d_bufsize, d_buffer + d_bufsize);
    }
    
  • 重写的 underflow 成员函数的实现几乎与 IFdStreambuf(第 26.1.2.1 节)成员相同。唯一的区别在于当前类支持较大尺寸的缓冲区。因此,可以一次从设备读取更多字符(最多 d_bufsize 个):

    int IFdNStreambuf::underflow()
    {
        if (gptr() < egptr())
            return *gptr();
    
        int nread = read(d_fd, d_buffer, d_bufsize);
    
        if (nread <= 0)
            return EOF;
    
        setg(d_buffer, d_buffer, d_buffer + nread);
        return static_cast<unsigned char>(*gptr());
    }
    
  • 最后,xsgetn 被重写。在循环中,n 被减少到 0,此时函数终止。或者,如果 underflow 未能获得更多字符,该成员函数将返回。此成员函数优化了读取一系列字符的过程。它不再调用 streambuf::sbumpc n 次,而是将 avail 字符块复制到目标,并使用 streambuf::gbump 通过一次函数调用从缓冲区中消耗 avail 字符:

    std::streamsize IFdNStreambuf::xsgetn(char *dest, std::streamsize n)
    {
        int nread = 0;
    
        while (n)
        {
            if (!in_avail())
            {
                if (underflow() == EOF)
                    break;
            }
    
            int avail = in_avail();
    
            if (avail > n)
                avail = n;
    
            memcpy(dest + nread, gptr(), avail);
            gbump(avail);
    
            nread += avail;
            n -= avail;
        }
    
        return nread;
    }
    
  • 成员函数 xsgetnstreambuf::sgetn 调用,它是 streambuf 的成员。以下示例演示了如何使用 IFdNStreambuf 对象的此成员函数:

    #include <unistd.h>
    #include <iostream>
    #include <istream>
    #include "ifdnbuf.h"
    using namespace std;
    
    int main()
    {
                                    // 内部:30 字符缓冲区
        IFdNStreambuf fds(STDIN_FILENO, 30);
    
        char buf[80];               // main() 读取 80 字符块
        while (true)
        {
            size_t n = fds.sgetn(buf, 80);
            if (n == 0)
                break;
            cout.write(buf, n);
        }
    }
    

streambuf 对象中寻找位置

当设备支持查找(seek)操作时,从 std::streambuf 派生的类应重写成员函数 streambuf::seekoffstreambuf::seekpos。在本节中开发的 IFdSeek 类可用于从支持查找操作的设备中读取信息。IFdSeek 类派生自 IFdStreambuf,因此它只使用一个字符的缓冲区。我们在新类 IFdSeek 中添加的查找操作功能,确保在请求查找操作时输入缓冲区会被重置。该类也可以从 IFdNStreambuf 类派生。在这种情况下,重置输入缓冲区的参数必须适应,使得其第二和第三个参数指向可用输入缓冲区之外的位置。让我们来看看 IFdSeek 的特点:

如前所述,IFdSeek 派生自 IFdStreambuf。与后者类一样,IFdSeek 的成员函数使用在 unistd.h 中声明的功能。因此,在编译类的成员函数之前,编译器必须已经读取了 <unistd.h> 头文件。为了减少在指定 streambufstd::ios 中类型和常量时的输入量,该类定义了几个 using 声明。这些 using 声明引用了在 <ios> 头文件中定义的类型,因此在编译 IFdSeek 类接口之前也必须包含 <ios> 头文件:

class IFdSeek: public IFdStreambuf
{
    using pos_type = std::streambuf::pos_type;
    using off_type = std::streambuf::off_type;
    using seekdir = std::ios::seekdir;
    using openmode = std::ios::openmode;

public:
    IFdSeek(int fd);
private:
    pos_type seekoff(off_type offset, seekdir dir, openmode);
    pos_type seekpos(pos_type offset, openmode mode);
};

该类的接口非常简单。它唯一的构造函数需要设备的文件描述符。它没有执行任何特殊任务,只是调用其基类构造函数:

inline IFdSeek::IFdSeek(int fd)
    : IFdStreambuf(fd)
{}

成员函数 seekoff 负责执行实际的查找操作。它调用 lseek 在文件描述符已知的设备中查找新位置。如果查找成功,则调用 setg 来定义一个已清空的缓冲区,以便基类的 underflow 成员函数在下一个输入请求时重新填充缓冲区。

IFdSeek::pos_type IFdSeek::seekoff(off_type off, seekdir dir, openmode)
{
    pos_type pos =
        lseek
        (
            d_fd, off,
            (dir ==  std::ios::beg) ? SEEK_SET :
            (dir ==  std::ios::cur) ? SEEK_CUR :
                                      SEEK_END
        );

    if (pos < 0)
        return -1;

    setg(d_buffer, d_buffer + 1, d_buffer + 1);
    return pos;
}

最后,重写了配套函数 seekpos:实际上,它被定义为对 seekoff 的一次调用:

inline IFdSeek::pos_type IFdSeek::seekpos(pos_type off, openmode mode)
{
    return seekoff(off, std::ios::beg, mode);
}

下面是一个使用 IFdSeek 类的程序示例。如果该程序通过输入重定向来读取它自己的源文件,则支持查找操作(除了第一行外,每一行都会显示两次):

#include "fdinseek.h"
#include <string>
#include <iostream>
#include <istream>
#include <iomanip>
using namespace std;

int main()
{
    IFdSeek fds(0);
    istream is(&fds);
    string  s;

    while (true)
    {
        if (!getline(is, s))
            break;

        streampos pos = is.tellg();

        cout << setw(5) << pos << ": `" << s << "'\n";

        if (!getline(is, s))
            break;

        streampos pos2 = is.tellg();

        cout << setw(5) << pos2 << ": `" << s << "'\n";

        if (!is.seekg(pos))
        {
            cout << "Seek failed\n";
            break;
        }
    }
}

streambuf 对象中的多次 unget 调用

streambuf 类及其派生类应至少支持将最后读取的字符返还(unget)。当必须支持一系列 unget 调用时,需要特别小心。在本节中,将讨论一个支持配置数量的 istream::ungetistream::putback 调用的类的构造方法。

支持多个(比如说 n 个)unget 调用的实现方式是预留输入缓冲区的初始部分,该部分逐步填充以包含最后读取的 n 个字符。该类的实现如下:

再次强调,该类派生自 std::streambuf。它定义了几个数据成员,使该类能够执行所需的记录工作,以维护一个具有可配置大小的 unget 缓冲区:

class FdUnget: public std::streambuf
{
    int     d_fd;
    size_t  d_bufsize;
    size_t  d_reserved;
    char   *d_buffer;
    char   *d_base;
public:
    FdUnget(int fd, size_t bufsz, size_t unget);
    ~FdUnget() override;
private:
    int underflow() override;
};

类的构造函数需要一个文件描述符、一个缓冲区大小和一个可以被返还或推回的字符数量作为参数。这个数量决定了一个保留区域的大小,该区域定义为类的输入缓冲区的前 d_reserved 个字节。

输入缓冲区将始终至少比 d_reserved 大一个字节。因此,可以读取一定数量的字节。一旦读取了 d_reserved 个字节,最多可以返还 d_reserved 个字节。

接下来,配置读取操作的起始点,称为 d_base,指向比 d_buffer 超过 d_reserved 字节的位置。这始终是缓冲区重新填充的起始位置。

现在缓冲区已经构建好,我们准备使用 setg 定义 streambuf 的缓冲区指针。由于尚未读取任何字符,所有指针都设置为指向 d_base。如果此时调用 unget,没有可用的字符,unget 将(正确地)失败。

最终,重新填充缓冲区的大小确定为已分配字节数减去保留区域的大小。

以下是类的构造函数:

FdUnget::FdUnget(int fd, size_t bufsz, size_t unget)
    : d_fd(fd),
      d_reserved(unget)
{
    size_t allocate =
            bufsz > d_reserved ?
                bufsz
            :
                d_reserved + 1;

    d_buffer = new char[allocate];

    d_base = d_buffer + d_reserved;
    setg(d_base, d_base, d_base);

    d_bufsize = allocate - d_reserved;
}

类的析构函数只是将分配给缓冲区的内存归还到公共池:

inline FdUnget::~FdUnget()
{
    delete[] d_buffer;
}

最后,underflow 被如下重写:

首先,underflow 确定可以返还的字符数量。如果返还了这些字符,输入缓冲区将耗尽。因此,这个值可以在 0(初始状态)到输入缓冲区的大小(当保留区域已完全填满,并且缓冲区剩余部分的所有当前字符也已被读取)之间。

接下来,计算要移动到保留区域的字节数。这个数字最多为 d_reserved,但如果实际可返还的字符数较小,则将其设置为该值。

现在已经知道要移动到保留区域的字符数,将此数量的字符从输入缓冲区的末尾移动到 d_base 之前的区域。

然后缓冲区重新填充。这一切都是标准操作,但请注意,读取是从 d_base 开始的,而不是从 d_buffer 开始。

最后,设置 streambuf 的读取缓冲区指针。eback 被设置为 d_base 之前的位置,从而定义了保证的 unget 区域,gptr 被设置为 d_base,因为这是重新填充后第一个读取字符的位置,egptr 则设置为缓冲区中最后一个读取字符的下一个位置。

以下是 underflow 的实现:

int FdUnget::underflow()
{
    size_t ungetsize = gptr() - eback();
    size_t move = std::min(ungetsize, d_reserved);

    memcpy(d_base - move, egptr() - move, move);

    int nread = read(d_fd, d_base, d_bufsize);
    if (nread <= 0)       // none read -> return EOF
        return EOF;

    setg(d_base - move, d_base, d_base + nread);

    return static_cast<unsigned char>(*gptr());
}

使用 FdUnget 的示例

下一个示例程序说明了 FdUnget 类的使用。它从标准输入读取最多 10 个字符,在 EOF 处停止。一个具有 2 个字符的保证 unget 缓冲区被定义在一个包含 3 个字符的缓冲区中。就在读取字符之前,程序尝试返还最多 6 个字符。当然,这是不可能的;但是程序很好地返还了尽可能多的字符,考虑到实际读取的字符数量:

#include "fdunget.h"
#include <string>
#include <iostream>
#include <istream>
using namespace std;

int main()
{
    FdUnget fds(0, 3, 2);
    istream is(&fds);
    char    c;

    for (int idx = 0; idx < 10; ++idx)
    {
        cout << "after reading " << idx << " characters:\n";
        for (int ug = 0; ug <= 6; ++ug)
        {
            if (!is.unget())
            {
                cout
                << "\tunget failed at attempt " << (ug + 1) << "\n"
                << "\trereading: '";

                is.clear();
                while (ug--)
                {
                    is.get(c);
                    cout << c;
                }
                cout << "'\n";
                break;
            }
        }

        if (!is.get(c))
        {
            cout << " reached\n";
            break;
        }
        cout << "Next character: " << c << '\n';
    }
}
/*
    程序在执行 'echo abcde | program' 后生成的输出:

    after reading 0 characters:
            unget failed at attempt 1
            rereading: ''
    Next character: a
    after reading 1 characters:
            unget failed at attempt 2
            rereading: 'a'
    Next character: b
    after reading 2 characters:
            unget failed at attempt 3
            rereading: 'ab'
    Next character: c
    after reading 3 characters:
            unget failed at attempt 4
            rereading: 'abc'
    Next character: d
    after reading 4 characters:
            unget failed at attempt 4
            rereading: 'bcd'
    Next character: e
    after reading 5 characters:
            unget failed at attempt 4
            rereading: 'cde'
    Next character:

    after reading 6 characters:
            unget failed at attempt 4
            rereading: 'de
    '
     reached
*/

istream 对象中提取定长字段

通常,当从 istream 对象中提取信息时,标准的提取操作符 operator>> 非常适合,因为在大多数情况下,提取的字段由空白或其他明显的分隔符分隔。然而,并非所有情况下都适用。例如,当网络表单提交到某些处理脚本或程序时,接收程序可能会收到表单字段的值,这些值以 URL 编码的字符形式传输:字母和数字原样发送,空格作为 + 号发送,其他所有字符以 % 开头,后跟该字符的 ASCII 值的两位十六进制表示。

在解码 URL 编码的信息时,简单的十六进制提取方法无法正常工作,因为它会提取尽可能多的十六进制字符,而不是仅仅提取两个字符。由于字母 a-f0-9 是合法的十六进制字符,因此像 “My name is Ed” 这样的文本被 URL 编码为:

My+name+is+%60Ed%27

结果是提取出的十六进制值为 60ed27,而不是 6027,从而导致名字 “Ed” 消失了,这显然不是我们想要的。

在这种情况下,看到 % 后,我们可以提取 2 个字符,将它们放入 istringstream 对象中,并从 istringstream 对象中提取十六进制值。虽然有点麻烦,但还是可以做到的。还有其他方法也可以实现。

用于定长字段 istream 的类 Fistream 定义了一个支持定长字段提取和空白分隔提取(以及非格式化读取调用)的 istream 类。该类可以作为现有 istream 的包装器初始化,也可以通过现有文件的名称初始化。该类派生自 istream,允许所有 istream 类对象支持的提取和操作。Fistream 定义了以下数据成员:

  • d_filebuf: 当 Fistream 从一个已命名(存在的)文件读取信息时使用的文件缓冲区。由于文件缓冲区仅在这种情况下需要,并且必须动态分配,因此它被定义为 unique_ptr<filebuf> 对象。
  • d_streambuf: 指向 Fistreamstreambuf 的指针。当 Fistream 通过文件名打开文件时,它指向 d_filebuf。当使用现有的 istream 来构造 Fistream 时,它指向现有 istreamstreambuf
  • d_iss: 用于定长字段提取的 istringstream 对象。
  • d_width: 表示要提取的字段宽度的 size_t 类型。如果为 0,则不使用定长字段提取,而是使用 istream 基类对象的标准提取方式。

以下是 Fistream 类接口的初始部分:

class Fistream: public std::istream
{
    std::unique_ptr<std::filebuf> d_filebuf;
    std::streambuf *d_streambuf;
    std::istringstream d_iss;
    size_t d_width;

正如所述,Fistream 对象可以通过文件名或现有的 istream 对象来构造。因此,该类接口声明了两个构造函数:

    Fistream(std::istream &stream);
    Fistream(char const *name, std::ios::openmode mode = std::ios::in);

当使用现有的 istream 对象构造 Fistream 对象时,Fistreamistream 部分只需使用该流的 streambuf 对象:

Fistream::Fistream(istream &stream)
: 
    istream(stream.rdbuf()), 
    d_streambuf(rdbuf()), 
    d_width(0) 
{}

当使用文件名构造 Fistream 对象时,istream 基类初始化器被赋予一个新的 filebuf 对象,用作其 streambuf。由于类的数据成员在类的基类构造之前没有初始化,因此 d_filebuf 只能在那之后初始化。此时,filebuf 仅作为 rdbuf 可用,返回一个 streambuf。然而,由于它实际上是 filebuf,因此使用 static_castrdbuf 返回的 streambuf 指针转换为 filebuf *,以便初始化 d_filebuf

Fistream::Fistream(char const *name, ios::openmode mode)
: 
    istream(new filebuf()), 
    d_filebuf(static_cast<filebuf *>(rdbuf())), 
    d_streambuf(d_filebuf.get()), 
    d_width(0)
{
    d_filebuf->open(name, mode);
}

成员函数和示例

只有一个附加的公共成员:setField(field const &)。该成员定义了要提取的下一个字段的大小。其参数是 field 类的引用,一个定义下一个字段宽度的操纵类。

由于在 Fistream 的接口中提到了 field &,因此必须在 Fistream 的接口开始之前声明 field 类。field 类本身很简单,并将 Fistream 声明为其友元。它有两个数据成员:d_width 指定下一个字段的宽度,d_newWidthd_width 的值实际使用时设置为 true。如果 d_newWidth 为 false,则 Fistream 返回到其标准提取模式。field 类有两个构造函数:一个默认构造函数,将 d_newWidth 设置为 false,另一个构造函数将下一个字段的宽度作为其值。以下是 field 类:

class field
{
    friend class Fistream;
    size_t d_width;
    bool   d_newWidth;

    public:
        field(size_t width);
        field();
};

inline field::field(size_t width)
: 
    d_width(width), 
    d_newWidth(true) 
{}

inline field::field() 
: 
    d_newWidth(false) 
{}

由于 fieldFistream 声明为其友元,因此 setField 可以直接检查 field 的成员。

现在回到 setField。这个函数期望一个初始化为三种不同方式之一的 field 对象的引用:

  • field(): 当 setField 的参数是由其默认构造函数构造的 field 对象时,下一个提取将使用与前一个提取相同的字段宽度。
  • field(0): 当这个 field 对象用作 setField 的参数时,定长字段提取停止,Fistream 再次像任何标准的 istream 对象一样工作。
  • field(x): 当 field 对象本身通过非零的 size_tx 初始化时,下一个字段宽度为 x 个字符。这样的字段的准备工作由 Fistream 唯一的私有成员 setBuffer 完成。

以下是 setField 的实现:

std::istream &Fistream::setField(field const &params)
{
    if (params.d_newWidth)                  // 请求新的字段大小
        d_width = params.d_width;           // 设置新宽度

    if (!d_width)                           // 没有宽度?
        rdbuf(d_streambuf);                 // 返回到旧缓冲区
    else
        setBuffer();                        // 定义提取缓冲区

    return *this;
}

私有成员 setBuffer 定义了一个 d_width + 1 个字符的缓冲区,并使用 read 来填充包含 d_width 个字符的缓冲区。缓冲区是一个 NTBS(空终止字符串)。该缓冲区用于初始化 d_iss 成员。Fistreamrdbuf 成员用于通过 Fistream 对象提取 d_str 的数据:

void Fistream::setBuffer()
{
    char *buffer = new char[d_width + 1];

    rdbuf(d_streambuf);                         // 使用 istream 的缓冲区
    buffer[read(buffer, d_width).gcount()] = 0; // 读取 d_width 个字符,
                                                // 并以 0 字节终止
    d_iss.str(buffer);
    delete[] buffer;

    rdbuf(d_iss.rdbuf());                       // 切换缓冲区
}

虽然可以使用 setFieldFistream 配置为使用或不使用定长字段提取,但使用操纵器可能更可取。为了允许 field 对象用作操纵器,定义了一个重载的提取操作符。这个提取操作符接受 istream &field const & 对象。使用此提取操作符,可以编写如下语句:

fis >> field(2) >> x >> field(0);

假设 fis 是一个 Fistream 对象。以下是重载的 operator>> 以及其声明:

istream &std::operator>>(istream &str, field const &params)
{
    return static_cast<Fistream *>(&str)->setField(params);
}

声明:

namespace std
{
    istream &operator>>(istream &str, FBB::field const &params);
}

最后是一个示例。以下程序使用 Fistream 对象对标准输入中出现的 URL 编码信息进行解码:

int main()
{
    Fistream fis(cin);

    fis >> hex;
    while (true)
    {
        size_t x;
        switch (x = fis.get())
        {
            case '\n':
                cout << '\n';
            break;
            case '+':
                cout << ' ';
            break;
            case '%':
                fis >> field(2) >> x >> field(0);
            // 继续执行
            default:
                cout << static_cast<char>(x);
            break;
            case EOF:
            return 0;
        }
    }
}
/*
    运行 echo My+name+is+%60Ed%27 | a.out 后产生的输出为:

    My name is `Ed'
*/

fork 系统调用

在 C 编程语言中,fork 系统调用是众所周知的。当程序需要启动一个新进程时,可以使用 system 函数。system 函数要求程序等待子进程终止后才能继续执行。而生成子进程的更通用的方法是使用 fork

本节将探讨如何在 C++ 中使用类来封装像 fork 这样复杂的系统调用。接下来的内容主要适用于 Unix 操作系统,因此讨论也主要集中在该操作系统上。其他系统通常也提供类似的功能。接下来的讨论与模板设计模式密切相关(参见 Gamma 等人著《设计模式》,Addison-Wesley 出版,1995 年)。

当调用 fork 时,当前程序会在内存中被复制,从而创建一个新进程。在这种复制完成后,两个进程会继续执行,从 fork 系统调用的下方开始。两个进程可以检查 fork 的返回值:在原始进程(称为父进程)中的返回值与在新创建的进程(称为子进程)中的返回值不同:

  • 在父进程中,fork 返回由 fork 系统调用创建的(子)进程的进程 ID。这是一个正整数值。
  • 在子进程中,fork 返回 0。
  • 如果 fork 失败,则返回 -1。

基本的 Fork

一个基本的 Fork 类应该隐藏系统调用 fork 的所有细节,使其用户无需关注这些复杂性。这里开发的 Fork 类正是为此目的而设计的。该类本身仅确保 fork 系统调用的正确执行。通常,fork 被调用来启动一个子进程,通常最终会执行一个单独的进程。这个子进程可能会在其标准输入流中接收输入,并/或在其标准输出和/或标准错误流中生成输出。Fork 并不知道这些,也不需要知道子进程将执行什么操作。Fork 对象应该能够启动它们的子进程。

Fork 的构造函数无法知道子进程应执行什么操作。同样地,它也无法知道父进程应执行什么操作。为了应对这些情况,开发了模板方法设计模式。根据 Gamma 等人所述,模板方法设计模式:

“定义了算法的框架,将一些步骤推迟到子类中。模板方法设计模式允许子类在不改变算法结构的情况下重新定义某些步骤。”

这种设计模式允许我们定义一个抽象基类,已经提供了与 fork 系统调用相关的基本步骤,将 fork 系统调用的其他部分的实现推迟到子类中。

Fork 抽象基类具有以下特点:

  • 它定义了一个数据成员 d_pid。在父进程中,该数据成员包含子进程的进程 ID,而在子进程中,它的值为 0。它的公共接口仅声明了两个成员:
    • fork 成员函数,负责实际的分叉(即创建新的子进程);
    • 虚析构函数 ~Fork(具有空函数体)。

以下是 Fork 的接口:

class Fork
{
    int d_pid;
public:
    virtual ~Fork();
    void fork();
protected:
    int pid() const;
    int waitForChild(); // 返回状态
private:
    virtual void childRedirections();
    virtual void parentRedirections();
    virtual void childProcess() = 0;
    virtual void parentProcess() = 0;
};
  • 所有其他非虚拟成员函数都声明在类的 protected 部分,因此只能由派生类使用。它们是:

    • pid():成员函数 pid 允许派生类访问系统 fork 的返回值:

      inline int Fork::pid() const
      {
          return d_pid;
      }
      
    • waitForChild():成员函数 waitForChild 可以由父进程调用,以等待其子进程完成(如下所述)。该成员在类接口中声明。其实现如下:

      #include "fork.ih"
      
      int Fork::waitForChild()
      {
          int status;
          waitpid(d_pid, &status, 0);
          return WEXITSTATUS(status);
      }
      

      这个简单的实现将子进程的退出状态返回给父进程。所调用的系统函数 waitpid 会阻塞,直到子进程终止。

  • 使用 fork 系统调用时,父进程和子进程必须始终加以区分。主要的区别在于:在父进程中,d_pid 是子进程的进程 ID,而在子进程中,d_pid 的值为 0。由于这两个进程必须始终加以区分且共存,它们的实现被强制在派生自 Fork 的类中实现:成员 childProcess 定义子进程的操作,parentProcess 定义父进程的操作,它们被定义为纯虚函数。

  • 父进程和子进程之间的通信可以使用标准流或其他设施,例如管道(参见 25.2.5 节)。为了便于这种进程间通信,派生类可以实现:

    • childRedirections():如果需要在子进程中重定向任何标准流(如 cincoutcerr),派生类应重写该成员(参见 25.2.3 节)。默认情况下,它具有空实现;
    • parentRedirections():如果需要在父进程中重定向任何标准流(如 cincoutcerr),派生类应重写该成员。默认情况下,它具有空实现。

    如果父进程和子进程需要通过标准流进行通信,则需要重定向标准流。以下是它们的默认定义。由于这些函数是虚函数,它们不应内联实现,而应在各自的源文件中实现:

    void Fork::childRedirections() {}
    void Fork::parentRedirections() {}
    

父进程和子进程

成员函数 fork 调用了系统函数 fork(注意:由于系统函数 fork 是通过同名的成员函数调用的,因此必须使用 :: 作用域解析运算符,以防止递归调用成员函数本身)。函数 ::fork 的返回值决定了是调用 parentProcess 还是 childProcess。在调用 childProcessparentProcess 之前,可能需要进行重定向。Fork::fork 的实现会在调用 childProcess 之前调用 childRedirections,在调用 parentProcess 之前调用 parentRedirections

#include "fork.ih"

void Fork::fork()
{
    if ((d_pid = ::fork()) < 0)
        throw "Fork::fork() failed";

    if (d_pid == 0) // 子进程的 pid == 0
    {
        childRedirections();
        childProcess();
        exit(1); // 子进程应终止 
    }// 不应该执行到这里:
    // childProcess() 应该退出
    parentRedirections();
    parentProcess();
}

fork.cc 中,类的内部头文件 fork.ih 被包含进来。这个头文件负责包含必要的系统头文件,以及 fork.h 本身。其实现如下:

#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include "fork.h"

子进程不应返回:一旦完成任务,它们应终止。这在子进程调用 exec 系列函数时会自动发生,但如果子进程本身仍然活跃,那么它必须确保适当地终止。子进程通常使用 exit 终止自身,但请注意,exit 会阻止在调用 exit 的相同或更浅嵌套层次中定义的对象析构函数的激活。全局定义的对象的析构函数在使用 exit 时会被激活。使用 exit 终止 childProcess 时,它要么调用一个支持成员函数来定义它所需的所有嵌套对象,要么在一个复合语句中定义其所有对象(例如,使用 throw 块),并在复合语句之后调用 exit

父进程通常应等待子进程完成。子进程终止时会向父进程发送信号,告知它们即将终止。如果子进程终止且父进程未捕获这些信号,那么这些子进程将作为所谓的僵尸进程继续存在。

如果父进程必须等待子进程完成,可以调用成员 waitForChild。该成员将子进程的退出状态返回给父进程。存在一种情况,即子进程继续存在,而父进程却死亡。这是一种相当自然的事件:父母往往比孩子先去世。在我们的上下文中(即 C++),这称为守护程序。在守护进程中,父进程终止,子进程继续作为基础 init 进程的子进程运行。当子进程最终死亡时,会向其“继父” init 发送信号。这不会创建僵尸进程,因为 init 会捕获所有其(继)子进程的终止信号。守护进程的构造非常简单,前提是有 Fork 类的可用性(参见 25.2.4 节)。

重定向重访

之前在第 6.6.2 节中,流通过 ios::rdbuf 成员函数进行重定向。通过将一个流的 streambuf 赋值给另一个流,这两个流对象都访问相同的 streambuf,从而在编程语言层面实现重定向。

在 C++ 程序的上下文中,这种做法是有效的,但一旦离开该上下文,重定向就会终止。操作系统并不知道 streambuf 对象。举例来说,当一个程序使用系统调用启动子程序时,就会遇到这种情况。本节末尾的示例程序使用 C++ 重定向将信息插入到 cout 中,并重定向到一个文件,然后调用

system("echo hello world");

来回显一行知名文本。由于 echo 将信息写入标准输出,如果操作系统识别 C++ 的重定向,这些信息本应写入程序的重定向文件。然而,重定向并没有发生。相反,hello world 仍然出现在程序的标准输出中,而重定向文件保持不变。为了将 hello world 写入重定向文件,重定向必须在操作系统层面上实现。一些操作系统(例如 Unix 及其类似系统)提供了像 dupdup2 这样的系统调用来完成这一任务。第 25.2.5 节提供了这些系统调用的使用示例。

以下是 C++ 重定向之后,系统级别重定向失败的示例:

#include <iostream>
#include <fstream>
#include <cstdlib>
using namespace std;

int main()
{
    ofstream of("outfile");
    streambuf *buf = cout.rdbuf(of.rdbuf());
    cout << "To the of stream\n";
    system("echo hello world");
    cout << "To the of stream\n";
    cout.rdbuf(buf);
}

生成的输出:在文件 outfile

To the of stream
To the of stream

在标准输出中:

hello world

守护进程(Daemon)程序

在某些应用程序中,fork 的唯一目的是启动一个子进程。父进程在生成子进程后会立即终止。如果发生这种情况,子进程将继续作为 init 进程的子进程运行,init 是 Unix 系统上始终运行的第一个进程。这样的进程通常称为守护进程,作为后台进程运行。

虽然下一个示例可以很容易地用纯 C 语言编写,但它被包含在 C++ 注释中,因为它与当前讨论的 Fork 类密切相关。我曾考虑在 Fork 类中添加一个守护进程成员,但最终决定不这样做,因为构造守护进程程序非常简单,只需要 Fork 类当前提供的功能即可。以下是一个示例,演示了如何构建这样一个守护进程程序。它的子进程不调用 exit,而是抛出 0,该异常在子进程的 main 函数的 catch 子句中被捕获。这样做可以确保子进程中定义的任何对象都能被正确销毁:

#include <iostream>
#include <unistd.h>
#include "fork.h"

class Daemon : public Fork
{
    void parentProcess() override
    // 父进程什么也不做。
    {}

    void childProcess() override
    // 子进程的操作
    {
        sleep(3);
        // 仅仅输出一条消息...
        std::cout << "Hello from the child process\n";
        throw 0;
        // 子进程结束
    }
};

int main()
try
{
    Daemon{}.fork();
}
catch(...)
{}

生成的输出:

接下来的命令提示符,然后在 3 秒后:
Hello from the child process

Pipe

在系统级别的重定向需要使用由 pipe 系统调用创建的文件描述符。当两个进程想要通过这些文件描述符进行通信时,会发生以下情况:

  • 进程通过 pipe 系统调用构造两个关联的文件描述符。其中一个文件描述符用于写入,另一个文件描述符用于读取。
  • 进行 fork 操作(即调用系统 fork 函数),这将复制文件描述符。现在,父进程和子进程各自拥有两个文件描述符的副本。
  • 一个进程(例如,父进程)使用文件描述符进行读取。它应该关闭用于写入的文件描述符。
  • 另一个进程(例如,子进程)使用文件描述符进行写入。因此,它应该关闭用于读取的文件描述符。
  • 子进程写入到用于写入的文件描述符的信息,现在可以被父进程从相应的用于读取的文件描述符中读取,从而在子进程和父进程之间建立一个通信通道。

虽然基本上很简单,但很容易出现错误。两个进程(子进程或父进程)可用的文件描述符函数可能会混淆。为了防止记录错误,可以在类 Pipe 中正确地设置记录,然后隐藏这些记录。以下是 Pipe 类的特性(在使用 pipedup 函数之前,编译器必须读取 <unistd.h> 头文件):

  • pipe 系统调用期望一个指向两个 int 值的指针,分别表示用于读取和用于写入的文件描述符。为了避免混淆,Pipe 类定义了一个枚举,具有将 2 个 int 数组索引与符号常量关联的值。两个文件描述符本身存储在数据成员 d_fd 中。

这是类接口的初始部分:

class Pipe
{
    enum RW { READ, WRITE };
    int d_fd[2];
  • 该类只需要一个默认构造函数。该构造函数调用 pipe 来创建一组用于访问管道两端的关联文件描述符:
Pipe::Pipe()
{
    if (pipe(d_fd))
        throw "Pipe::Pipe(): pipe() failed";
}
  • readOnlyreadFrom 成员函数用于配置管道的读取端。后者函数在使用重定向时使用。它提供一个备用文件描述符用于从管道读取。通常,这个备用文件描述符是 STDIN_FILENO,允许 cin 从管道中提取信息。前者函数仅用于配置管道的读取端。它关闭匹配的写入端,并返回一个可以用于从管道中读取的文件描述符:
int Pipe::readOnly()
{
    close(d_fd[WRITE]);
    return d_fd[READ];
}

void Pipe::readFrom(int fd)
{
    readOnly();
    redirect(d_fd[READ], fd);
    close(d_fd[READ]);
}
  • writeOnlywrittenBy 成员函数可用于配置管道的写入端。前者函数仅用于配置管道的写入端。它关闭读取端,并返回一个可以用于写入管道的文件描述符:
int Pipe::writeOnly()
{
    close(d_fd[READ]);
    return d_fd[WRITE];
}

writtenBy 有两个重载版本:

  • writtenBy(int fd) 用于配置单个重定向,以便特定的文件描述符(通常是 STDOUT_FILENOSTDERR_FILENO)可以用于写入管道;
  • writtenBy(int const *fd, size_t n) 用于配置多个重定向,提供一个包含文件描述符的数组作为参数。信息写入这些文件描述符中的任何一个,实际上都会写入管道。
void Pipe::writtenBy(int fd)
{
    writtenBy(&fd, 1);
}

void Pipe::writtenBy(int const *fd, size_t n)
{
    writeOnly();
    for (size_t idx = 0; idx < n; idx++)
        redirect(d_fd[WRITE], fd[idx]);
    close(d_fd[WRITE]);
}
  • 该类有一个私有数据成员 redirect,用于通过 dup2 系统调用设置重定向。该函数需要两个文件描述符。第一个文件描述符表示可以用于访问设备信息的文件描述符;第二个文件描述符是一个备用文件描述符,也可以用于访问设备的信息。下面是 redirect 的实现:
void Pipe::redirect(int d_fd, int alternateFd)
{
    if (dup2(d_fd, alternateFd) < 0)
        throw "Pipe: redirection failed";
}

现在,可以使用一个或多个 Pipe 对象轻松配置重定向。我们将在各种示例程序中使用 ForkPipe

ParentSlurp

ParentSlurp 继承自 Fork,用于启动一个子进程执行独立程序(如 /bin/ls)。所执行程序的标准输出不会显示在屏幕上,而是由父进程读取。为了演示,父进程将接收到的行写入其标准输出流,并在行前添加行号。程序中唯一的管道用作父进程的输入管道和子进程的输出管道。

ParentSlurp 具有以下特性:

  • 继承自 Fork。在开始 ParentSlurp 的类接口之前,编译器必须读取 fork.hpipe.h。该类只使用一个数据成员,即 Pipe 对象 d_pipe
  • 由于 Pipe 的构造函数已经定义了一个管道,并且 d_pipeParentSlurp 的默认构造函数自动初始化,所有额外成员仅存在于 ParentSlurp 自身的受保护部分。以下是类的接口:
class ParentSlurp: public Fork
{
    Pipe d_pipe;
    void childRedirections() override;
    void parentRedirections() override;
    void childProcess() override;
    void parentProcess() override;
};
  • childRedirections 成员函数配置管道的写入端。这样,写入到子进程标准输出流的信息都会进入管道。这大大简化了编程,不需要额外的流来写入文件描述符:
inline void ParentSlurp::childRedirections()
{
    d_pipe.writtenBy(STDOUT_FILENO);
}
  • parentRedirections 成员函数配置管道的读取端。它通过将管道的读取端连接到父进程的标准输入文件描述符 (STDIN_FILENO) 来实现。这使得父进程可以从 cin 中提取信息,不需要额外的流进行读取:
inline void ParentSlurp::parentRedirections()
{
    d_pipe.readFrom(STDIN_FILENO);
}
  • childProcess 成员函数仅需要专注于自己的操作。由于它只需执行一个程序(将信息写入标准输出),所以成员函数可以只包含一个语句:
inline void ParentSlurp::childProcess()
{
    execl("/bin/ls", "/bin/ls", 0);
}
  • parentProcess 成员函数“吞吐”标准输入中出现的信息。实际上,它读取了子进程的输出。它将接收到的行复制到标准输出流,并在行前添加行号:
void ParentSlurp::parentProcess()
{
    std::string line;
    size_t nr = 1;

    while (getline(std::cin, line))
        std::cout << nr++ << ": " << line << '\n';
    waitForChild();
}

以下程序简单地构造一个 ParentSlurp 对象,并调用其 fork() 成员。其输出是启动程序所在目录中文件的编号列表。注意,程序还需要 fork.opipe.owaitforchild.o 对象文件(见早期源代码):

int main()
{
    ParentSlurp{}.fork();
}

生成的输出(仅为示例,实际输出可能有所不同):

1: a.out
2: bitand.h
3: bitfunctional
4: bitnot.h
5: daemon.cc
6: fdinseek.cc
7: fdinseek.h
...

与多个子进程通信

接下来的步骤是构建一个子进程监控器。在这种情况下,父进程负责管理所有子进程,并且还必须读取它们的标准输出。用户在父进程的标准输入中输入信息,使用一个简单的命令语言:

  • start:启动一个新的子进程。父进程将子进程的 ID(一个数字)返回给用户。该 ID 用于标识特定的子进程;
  • <nr> text:向 ID 为 <nr> 的子进程发送“text”;
  • stop <nr>:终止 ID 为 <nr> 的子进程;
  • exit:终止父进程及其所有子进程。

如果子进程在一段时间内没有收到文本,它会发送消息给父进程来抱怨。这些消息通过复制到标准输出流的方式传递给用户。

这类程序面临的问题是,它们允许来自多个源的异步输入。输入可能出现在标准输入和管道的输入端口上。同时,使用了多个输出通道。为了处理这些情况,开发了 select 系统调用。

Selector:接口

select 系统调用被开发用来处理异步 I/O 多路复用。select 函数用于同时处理一组文件描述符上的输入。select 函数相对复杂,其详细讨论超出了 C++ 注释的范围。通过将 select 封装在 Selector 类中,隐藏其细节并提供直观的接口,可以简化其使用。Selector 类具有以下特性:

  • 效率:由于 select 的大多数成员非常小,因此大部分成员可以内联实现。该类需要相当多的数据成员。这些数据成员中的大部分属于需要先包含一些系统头文件的类型:

    #include <unistd.h>
    #include <sys/time.h>
    #include <sys/types.h>
    
  • 类接口
    数据类型 fd_set 是为 select 设计的类型,变量的这种类型包含 select 可能感知活动的文件描述符集合。此外,select 允许我们触发一个异步警报。为了设置警报时间,Selector 类定义了一个 timeval 数据成员。其他成员用于内部书keeping。以下是 Selector 类的接口:

    class Selector
    {
        fd_set          d_read;
        fd_set          d_write;
        fd_set          d_except;
        fd_set          d_ret_read;
        fd_set          d_ret_write;
        fd_set          d_ret_except;
        timeval         d_alarm;
        int             d_max;
        int             d_ret;
        int             d_readidx;
        int             d_writeidx;
        int             d_exceptidx;
    
    public:
        Selector();
    
        int exceptFd();
        int nReady();
        int readFd();
        int wait();
        int writeFd();
        void addExceptFd(int fd);
        void addReadFd(int fd);
        void addWriteFd(int fd);
        void noAlarm();
        void rmExceptFd(int fd);
        void rmReadFd(int fd);
        void rmWriteFd(int fd);
        void setAlarm(int sec, int usec = 0);
    
    private:
        int checkSet(int *index, fd_set &set);
        void addFd(fd_set *set, int fd);
    };
    

Selector:实现

Selector 类的成员函数执行以下任务:

  • Selector():默认构造函数。它清除 readwriteexceptfd_set 变量,并关闭警报。除了 d_max 外,其余数据成员不需要特定的初始化:

    Selector::Selector()
    {
        FD_ZERO(&d_read);
        FD_ZERO(&d_write);
        FD_ZERO(&d_except);
        noAlarm();
        d_max = 0;
    }
    
  • int wait():此成员函数阻塞,直到警报超时或在 Selector 对象监控的任何文件描述符上检测到活动。如果 select 系统调用本身失败,则抛出异常:

    int Selector::wait()
    {
        timeval t = d_alarm;
        d_ret_read = d_read;
        d_ret_write = d_write;
        d_ret_except = d_except;
        d_readidx = 0;
        d_writeidx = 0;
        d_exceptidx = 0;
        d_ret = select(d_max, &d_ret_read, &d_ret_write, &d_ret_except,
                       t.tv_sec == -1 && t.tv_usec == -1 ? 0 : &t);
        if (d_ret < 0)
            throw "Selector::wait()/select() failed";
        return d_ret;
    }
    
  • int nReady():此成员函数的返回值仅在 wait 返回后定义。在这种情况下,它返回 0 表示警报超时,-1 表示 select 失败,否则返回检测到活动的文件描述符的数量:

    inline int Selector::nReady()
    {
        return d_ret;
    }
    
  • int readFd():此成员函数的返回值也仅在 wait 返回后定义。它的返回值为 -1 表示没有(更多)可用的输入文件描述符,否则返回下一个可读取的文件描述符:

    inline int Selector::readFd()
    {
        return checkSet(&d_readidx, d_ret_read);
    }
    
  • int writeFd():类似于 readFd,它返回下一个可以写入输出的文件描述符。它使用 d_writeidxd_ret_write,其实现类似于 readFd

    inline int Selector::writeFd()
    {
        return checkSet(&d_writeidx, d_ret_write);
    }
    
  • int exceptFd():类似于 readFd,它返回下一个在其上检测到异常的文件描述符。它使用 d_exceptidxd_ret_except,其实现类似于 readFd

    inline int Selector::exceptFd()
    {
        return checkSet(&d_exceptidx, d_ret_except);
    }
    
  • void setAlarm(int sec, int usec = 0):此成员函数激活 select 的警报功能。必须指定等待警报触发的秒数。它简单地将值分配给 d_alarm 的字段。在下一次 Selector::wait 调用时,配置的警报间隔一过,警报触发(即 wait 返回值为 0):

    inline void Selector::setAlarm(int sec, int usec)
    {
        d_alarm.tv_sec = sec;
        d_alarm.tv_usec = usec;
    }
    
  • void noAlarm():此成员函数关闭警报,通过将警报间隔设置为非常长的时间:

    inline void Selector::noAlarm()
    {
        setAlarm(-1, -1);
    }
    
  • void addReadFd(int fd):此成员函数将文件描述符添加到 Selector 对象监控的输入文件描述符集合中。成员函数 wait 会在指定的文件描述符上有输入时返回:

    inline void Selector::addReadFd(int fd)
    {
        addFd(&d_read, fd);
    }
    
  • void addWriteFd(int fd):此成员函数将文件描述符添加到 Selector 对象监控的输出文件描述符集合中。成员函数 wait 会在指定的文件描述符上有输出时返回。使用 d_write,其实现类似于 addReadFd

    inline void Selector::addWriteFd(int fd)
    {
        addFd(&d_write, fd);
    }
    
  • void addExceptFd(int fd):此成员函数将文件描述符添加到 Selector 对象监控的异常文件描述符集合中。成员函数 wait 会在指定的文件描述符上检测到异常时返回。使用 d_except,其实现类似于 addReadFd

    inline void Selector::addExceptFd(int fd)
    {
        addFd(&d_except, fd);
    }
    
  • void rmReadFd(int fd):此成员函数从 Selector 对象监控的输入文件描述符集合中删除文件描述符:

    inline void Selector::rmReadFd(int fd)
    {
        FD_CLR(fd, &d_read);
    }
    
  • void rmWriteFd(int fd):此成员函数从 Selector 对象监控的输出文件描述符集合中删除文件描述符。使用 d_write,其实现类似于 rmReadFd

    inline void Selector::rmWriteFd(int fd)
    {
        FD_CLR(fd, &d_write);
    }
    
  • void rmExceptFd(int fd):此成员函数从 Selector 对象监控的异常文件描述符集合中删除文件描述符。使用 d_except,其实现类似于 rmReadFd

    inline void Selector::rmExceptFd(int fd)
    {
        FD_CLR(fd, &d_except);
    }
    

类的剩余两个成员是支持成员,不应由非成员函数使用。因此,它们被声明在类的私有部分:

  • void addFd(fd_set *set, int fd):此成员函数将文件描述符添加到 fd_set 中:

    void Selector::addFd(fd_set *set, int fd)
    {
        FD_SET(fd, set);
        if (fd >= d_max)
            d_max = fd + 1;
    }
    
  • int checkSet(int *index, fd_set &set):此成员函数测试文件描述符 (*index) 是否在 fd_set 中:

    int Selector::checkSet(int *index, fd_set &set)
    {
        int &idx = *index;
        while (idx < d_max && !FD_ISSET(idx, &set))
            ++idx;
        return idx == d_max ? -1 : idx++;
    }
    

Monitor:接口

Monitor 程序使用一个 Monitor 对象来执行大部分工作。Monitor 类的公共接口仅提供一个默认构造函数和一个成员函数 run 来执行任务。所有其他成员函数都位于类的私有部分。

Monitor 定义了一个私有的 enum Commands,符号化列出了其输入语言支持的各种命令,以及几个数据成员。数据成员中包括一个 Selector 对象和一个使用子进程顺序号作为键、指向 Child 对象的指针作为值的 map。此外,Monitor 还有一个静态数组成员 s_handler[],存储指向处理用户命令的成员函数的指针。

应实现一个析构函数,但其实现留给读者作为练习。以下是 Monitor 的接口,包括用于创建函数对象的嵌套类 Find 的接口:

class Monitor
{
    enum Commands
    {
        UNKNOWN,
        START,
        EXIT,
        STOP,
        TEXT,
        sizeofCommands
    };

    using MapIntChild = std::map<int, std::shared_ptr<Child>>;

    friend class Find;
    class Find
    {
        int d_nr;
        public:
            Find(int nr);
            bool operator()(MapIntChild::value_type &vt) const;
    };

    Selector d_selector;
    int d_nr;
    MapIntChild d_child;

    static void (Monitor::*s_handler[])(int, std::string const &);
    static int s_initialize;

    public:
        enum Done
        {};

        Monitor();
        void run();

    private:
        static void killChild(MapIntChild::value_type it);
        static int initialize();

        Commands next(int *value, std::string *line);
        void processInput();
        void processChild(int fd);

        void createNewChild(int, std::string const &);
        void exiting(int = 0, std::string const &msg = std::string{});
        void sendChild(int value, std::string const &line);
        void stopChild(int value, std::string const &);
        void unknown(int, std::string const &);
};

由于只有一个非类类型的数据成员,类的构造函数是一个非常简单的函数,可以内联实现:

inline Monitor::Monitor()
:
    d_nr(0)
{}

Monitor:s_handler

数组 s_handler 存储指向函数的指针,也需要初始化。这可以通过几种方式来完成:

  1. 编译时初始化:
    由于 Commands 枚举只指定了一组相对有限的命令,可以考虑在编译时进行初始化:

    void (Monitor::*Monitor::s_handler[])(int, std::string const &) =
    {
        &Monitor::unknown,
        // 顺序遵循 Commands 枚举的顺序
        &Monitor::createNewChild,
        &Monitor::exiting,
        &Monitor::stopChild,
        &Monitor::sendChild,
    };
    

    这样做的优点是简单,不需要任何运行时的工作。然而,缺点是维护相对复杂。如果由于某种原因修改了 Commands,则需要同时修改 s_handler。在这种情况下,编译时初始化通常容易出问题。不过,还有一个简单的替代方案。

  2. 运行时初始化:
    通过查看 Monitor 的接口,我们可以看到一个静态数据成员 s_initialize 和一个静态成员函数 initialize。静态成员函数负责初始化 s_handler 数组。它显式地分配数组的元素,并且枚举 Commands 的值的任何修改顺序都会通过重新编译 initialize 来自动处理:

    void (Monitor::*Monitor::s_handler[sizeofCommands])(int, std::string const &);
    
    int Monitor::initialize()
    {
        s_handler[UNKNOWN] =    &Monitor::unknown;
        s_handler[START] =      &Monitor::createNewChild;
        s_handler[EXIT] =       &Monitor::exiting;
        s_handler[STOP] =       &Monitor::stopChild;
        s_handler[TEXT] =       &Monitor::sendChild;
        return 0;
    }
    

    静态成员 initialize 可以被调用来初始化 s_initialize,这是一个静态整数变量。初始化的强制执行是通过将初始化语句放在已知会被执行的函数的源文件中来实现的。它可以是 main 函数,但如果我们是 Monitor 的维护者,只能控制包含 Monitor 代码的库,则这不是一个选项。在这种情况下,包含析构函数的源文件是一个很好的候选。对于一个只有一个构造函数并且没有内联定义的类,构造函数的源文件也是一个很好的选择。在 Monitor 当前的实现中,初始化语句放在 run 函数的源文件中,理由是 s_handler 只有在 run 被使用时才需要。

Monitor:成员 run

Monitor 的核心活动由 run 执行。它执行以下任务:

  • 初始化: Monitor 对象最初只监视标准输入。要监听的输入文件描述符集合被初始化为 STDIN_FILENO
  • 循环处理: 然后,在一个循环中调用 d_selectorwait 函数。如果标准输入 (cin) 有输入,processInput 会处理这些输入。否则,输入来自子进程,processChild 会处理这些来自子进程的信息。
  • 防止僵尸进程: 子进程必须捕获其子进程的终止信号。这一问题将在下面讨论。

如 Ben Simons(ben@mrxfx.com)所述,Monitor 不能捕获终止信号。相反,产生子进程的进程应当承担这个责任(基本原则是父进程负责其子进程,而子进程则负责自己的子进程)。

  • 初始化: 如上所述,run 的源文件还定义并初始化了 s_initialize,以确保 s_handler 数组的正确初始化。

以下是 run 的实现和 s_initialize 的定义:

#include "monitor.ih"

int Monitor::s_initialize = Monitor::initialize();

void Monitor::run()
{
    d_selector.addReadFd(STDIN_FILENO);
    while (true)
    {
        cout << "? " << flush;
        try
        {
            d_selector.wait();
            int fd;
            while ((fd = d_selector.readFd()) != -1)
            {
                if (fd == STDIN_FILENO)
                    processInput();
                else
                    processChild(fd);
            }
            cout << "NEXT ...\n";
        }
        catch (char const *msg)
        {
            exiting(1, msg);
        }
    }
}

成员函数 processInputnext

  • processInput 读取用户通过程序标准输入流输入的命令。该成员函数相当简单。它调用 next 来获取用户输入的下一个命令,然后使用 s_handler[] 数组中匹配的元素调用相应的函数。
void Monitor::processInput()
{
    string line;
    int value;
    Commands cmd = next(&value, &line);
    (this->*s_handler[cmd])(value, line);
}
  • next 读取命令并返回对应的 Commands 枚举值。它还将命令的参数值和行内容传递给 processInput
Monitor::Commands Monitor::next(int *value, string *line)
{
    if (!getline(cin, *line))
        exiting(1, "Monitor::next(): reading cin failed");

    if (*line == "start")
        return START;

    if (*line == "exit" || *line == "quit")
    {
        *value = 0;
        return EXIT;
    }

    if (line->find("stop") == 0)
    {
        istringstream istr(line->substr(4));
        istr >> *value;
        return !istr ? UNKNOWN : STOP;
    }

    istringstream istr(line->c_str());
    istr >> *value;
    if (istr)
    {
        getline(istr, *line);
        return TEXT;
    }

    return UNKNOWN;
}

所有由 d_selector 监测到的其他输入都是由子进程产生的。由于 d_selectorreadFd 成员返回相应的输入文件描述符,这个描述符可以传递给 processChild。通过 IFdStreambuf(见第 25.1.2.1 节),从输入流中读取子进程的信息。这里使用的通信协议相当基础:对于发送到子进程的每一行输入,子进程会回复一行文本。这一行文本由 processChild 读取:

void Monitor::processChild(int fd)
{
    IFdStreambuf ifdbuf(fd);
    istream istr(&ifdbuf);
    string line;

    getline(istr, line);
    cout << d_child[fd]->pid() << ": " << line << '\n';
}

上述源代码中的 d_child[fd]->pid() 构造需要特别注意。Monitor 定义了数据成员 map<int, shared_ptr<Child>> d_child。这个映射包含子进程的顺序号作为键,Child 对象的(共享)指针作为值。这里使用 shared_ptr 而不是 Child 对象,是因为我们希望使用映射提供的功能,但不希望每次都复制一个 Child 对象。

Monitor:示例

现在已经覆盖了 run 的实现,我们将集中讨论用户可能输入的各种命令:

  • 当发出 start 命令时,会启动一个新的子进程。通过成员函数 createNewChildd_child 中添加一个新元素。接下来,Child 对象应该启动它的活动,但 Monitor 对象不能等待子进程完成它的活动,因为没有明确的结束点,而且用户可能希望能够输入更多命令。因此,子进程必须以守护进程的形式运行。即,fork 出的进程立即终止,但它的子进程继续在后台运行。因此,createNewChild 调用子进程的 fork 成员函数。虽然调用的是子进程的 fork 函数,但这个 fork 函数的调用仍然发生在监控程序中。因此,监控程序被 fork 复制,执行接着继续:

    • 在子进程的 parentProcess 中,执行父进程的代码;
    • 在子进程的 childProcess 中,执行子进程的代码。

    因为子进程的 parentProcess 是一个空函数,立即返回,所以子进程的父进程实际上在 createNewChildcp->fork() 语句下立即继续。由于子进程从不返回(见第 25.2.7.7 节),子进程从不执行 cp->fork() 下面的代码。这正是预期的结果。

    在父进程中,createNewChild 剩下的代码只是将从子进程读取信息的文件描述符添加到 d_selector 监视的输入文件描述符集合中,并使用 d_child 将该文件描述符与 Child 对象的地址建立关联:

    void Monitor::createNewChild(int, string const &)
    {
        Child *cp = new Child{ ++d_nr };
        cp->fork();
        int fd = cp->readFd();
        d_selector.addReadFd(fd);
        d_child[fd].reset(cp);
        cerr << "Child " << d_nr << " started\n";
    }
    
  • 对于 stop <nr><nr> text 命令,需要直接与子进程通信。前者命令通过调用 stopChild 来终止子进程 <nr>。这个函数使用 Monitor 内部定义的 Find 类的匿名对象来定位具有顺序号的子进程。Find 类简单地将提供的 nr 与子进程的顺序号进行比较:

    inline Monitor::Find::Find(int nr)
    : d_nr(nr)
    {}
    
    inline bool Monitor::Find::operator()(MapIntChild::value_type &vt) const
    {
        return d_nr == vt.second->nr();
    }
    

    如果找到了具有顺序号 nr 的子进程,它的文件描述符将从 d_selector 的输入文件描述符集合中删除。然后通过静态成员 killChild 终止子进程。成员函数 killChild 被声明为静态成员函数,因为它在 exiting 中作为 for_each 泛型算法的函数参数使用(见下文)。killChild 的实现如下:

    void Monitor::killChild(MapIntChild::value_type it)
    {
        if (kill(it.second->pid(), SIGTERM))
            cerr << "Couldn't kill process " << it.second->pid() << '\n';
    
        // 回收无用的子进程
        int status = 0;
        while (waitpid(it.second->pid(), &status, WNOHANG) > -1)
        {}
    }
    

    终止指定的子进程后,相应的 Child 对象被销毁,指针从 d_child 中删除:

    void Monitor::stopChild(int nr, string const &)
    {
        auto it = find_if(d_child.begin(), d_child.end(), Find{ nr });
    
        if (it == d_child.end())
            cerr << "No child number " << nr << '\n';
        else
        {
            d_selector.rmReadFd(it->second->readFd());
            d_child.erase(it);
        }
    }
    
  • 命令 <nr> text 使用 sendChild 成员函数将文本发送到子进程 nr。此函数还使用 Find 对象来定位具有顺序号 nr 的子进程,并简单地将文本插入连接到该子进程的管道的写入端:

    void Monitor::sendChild(int nr, string const &line)
    {
        auto it = find_if(d_child.begin(), d_child.end(), Find(nr));
        if (it == d_child.end())
            cerr << "No child number " << nr << '\n';
        else
        {
            OFdnStreambuf ofdn{ it->second->writeFd() };
            ostream out(&ofdn);
    
            out << line << '\n';
        }
    }
    
  • 当用户输入 exitquit 命令时,会调用 exiting 成员函数。这个函数使用 for_each 泛型算法(见第 19.1.18 节)遍历 d_child 中的所有元素,以终止所有子进程。然后程序本身结束:

    void Monitor::exiting(int value, string const &msg)
    {
        for_each(d_child.begin(), d_child.end(), killChild);
        if (msg.length())
            cerr << msg << '\n';
        throw value;
    }
    

程序的 main 函数简单明了,无需进一步解释:

int main() try
{
    Monitor{}.run(); 
}
catch (int exitValue)
{
    return exitValue;
}

Child

Monitor 对象启动子进程时,它会创建一个 Child 类的对象。Child 类继承自 Fork 类,使其能够作为守护进程运行(如前面所述)。由于 Child 是一个守护进程类,我们知道它的父进程必须定义为一个空函数。它的 childProcess 成员有一个非空的实现。以下是 Child 类的特征:

  • Child 类有两个 Pipe 数据成员,用于处理其子进程和父进程之间的通信。由于这些管道被 Child 的子进程使用,它们的名称指代子进程。子进程从 d_in 读取数据,向 d_out 写入数据。Child 类的接口如下:

    class Child: public Fork
    {
        Pipe d_in;
        Pipe d_out;
        int d_parentReadFd;
        int d_parentWriteFd;
        int d_nr;
    
    public:
        Child(int nr);
        ~Child() override;
        int readFd() const;
        int writeFd() const;
        int pid() const;
        int nr() const;
    
    private:
        void childRedirections() override;
        void parentRedirections() override;
        void childProcess() override;
        void parentProcess() override;
    };
    
  • Child 类的构造函数简单地将其参数(子进程的顺序号)存储在 d_nr 数据成员中:

    inline Child::Child(int nr)
    : d_nr(nr)
    {}
    
  • Child 的子进程从其标准输入流中获取命令,并将输出写入其标准输出流。由于实际的通信通道是管道,因此必须使用重定向。childRedirections 成员函数如下:

    void Child::childRedirections()
    {
        d_in.readFrom(STDIN_FILENO);
        d_out.writtenBy(STDOUT_FILENO);
    }
    
  • 虽然父进程不执行任何操作,但它必须配置一些重定向。由于管道的名称表示它们在子进程中的功能,因此父进程写入 d_in 并从 d_out 读取。parentRedirections 如下:

    void Child::parentRedirections()
    {
        d_parentReadFd = d_out.readOnly();
        d_parentWriteFd = d_in.writeOnly();
    }
    
  • Child 对象存在直到被 MonitorstopChild 成员销毁。通过允许其创建者 Monitor 对象访问管道的父端,Monitor 可以通过这些管道端与 Child 的子进程进行通信。成员函数 readFdwriteFd 允许 Monitor 对象访问这些管道端:

    inline int Child::readFd() const
    {
        return d_parentReadFd;
    }
    
    inline int Child::writeFd() const
    {
        return d_parentWriteFd;
    }
    
  • Child 对象的子进程执行两个任务:

    • 必须对标准输入流中出现的信息做出回复;
    • 如果在一定时间内没有出现信息(实现中使用了五秒的间隔),则向标准输出流中写入一条消息。

    为了实现这一行为,childProcess 定义了一个局部 Selector 对象,将 STDIN_FILENO 添加到其监视的输入文件描述符集合中。然后,在一个无限循环中,childProcess 等待 selector.wait() 返回。当闹钟响起时,它向标准输出(因此,向写入管道)发送一条消息。否则,它将标准输入中的消息回显到标准输出。childProcess 成员函数如下:

    void Child::childProcess()
    {
        Selector selector;
        size_t message = 0;
        selector.addReadFd(STDIN_FILENO);
        selector.setAlarm(5);
        while (true)
        {
            try
            {
                if (!selector.wait())
                {
                    // 超时
                    cout << "Child " << d_nr << ": standing by\n";
                }
                else
                {
                    string line;
                    getline(cin, line);
                    cout << "Child " << d_nr << ":" << ++message << ": " << line << '\n';
                }
            }
            catch (...)
            {
                cout << "Child " << d_nr << ":" << ++message << ": " << "select() failed" << '\n';
            }
        }
        exit(0);
    }
    
  • 定义了两个访问器,允许 Monitor 对象获取 Child 的进程 ID 和顺序号:

    inline int Child::pid() const
    {
        return Fork::pid();
    }
    
    inline int Child::nr() const
    {
        return d_nr;
    }
    
  • 当用户输入 stop 命令时,Child 进程终止。当输入了一个已存在的子进程顺序号时,相应的 Child 对象从 Monitord_child 映射中移除。结果,其析构函数被调用。Child 的析构函数调用 kill 以终止其子进程,然后等待子进程终止。一旦子进程终止,析构函数完成工作并返回,从而完成从 d_child 中的删除。如果子进程没有响应 SIGTERM 信号,当前实现将失败。在实际应用中,可能需要更复杂的终止程序(例如,除了 SIGTERM 之外还使用 SIGKILL)。如第 10.12 节所述,确保适当的销毁非常重要。Child 的析构函数如下:

    Child::~Child()
    {
        if (pid())
        {
            cout << "Killing process " << pid() << "\n";
            kill(pid(), SIGTERM);
            int status;
            wait(&status);
        }
    }
    

向类添加二元运算符

如第 11.7 节所示,期望 const & 参数的二元运算符可以使用实现该操作的成员函数来实现,仅提供基本异常保证。这个成员函数又可以通过二元赋值成员函数来实现。以下示例展示了这种方法在一个虚构的 Binary 类中的应用:

class Binary
{
public:
    Binary();
    Binary(int value);
    // 复制和移动构造函数可以默认提供,或
    // 显式声明和实现。
    Binary &operator+=(Binary const &other) &;
    // 见正文
    Binary &&operator+=(Binary const &other) &&;
private:
    void add(Binary const &rhs);
    friend Binary operator+(Binary const &lhs, Binary const &rhs);
    friend Binary operator+(Binary &&lhs, Binary const &rhs);
};

最终,二元运算符的实现依赖于实现基本二元操作的成员函数,该函数修改调用该成员函数的对象(例如,在示例中是 void Binary::add(Binary const &))。

由于模板函数在实际使用之前不会被实例化,我们可以在模板函数中调用不存在的函数。如果这样的模板函数从未被实例化,则不会发生任何事情;如果它被(意外地)实例化,则编译器会生成一个错误消息,抱怨缺少该函数。

这允许我们将所有的二元运算符(可移动的和不可移动的)作为模板实现。在接下来的子节中,我们将开发一个类模板 Binops,提供二元运算符。一个完整的示例实现,展示了如何将加法和插入运算符添加到类中,详见 C++ Annotations 的源档案中的文件 annotations/yo/concrete/examples/binopclasses.cc

仅使用运算符

在第 11.7 节中,add 的实现是通过一个支持成员函数来完成的。这种方法在开发函数模板时不太吸引人,因为 add 是一个私有成员,需要为所有函数模板提供友元声明,以便它们可以访问私有的 add 成员。

在第 11.7 节末尾,我们看到 add 的实现可以通过 operator+=(Class const &rhs) && 提供。这使得在实现其余的加法运算符时可以利用这个运算符:

inline Binary &operator+=(Binary const &rhs) &
{
    // 实现代码
    return *this = Binary{*this} += rhs;
}

Binary operator+(Binary &&lhs, Binary const &rhs)
{
    return std::move(lhs) += rhs;
}

Binary operator+(Binary const &lhs, Binary const &rhs)
{
    return Binary{lhs} += rhs;
}

在这种实现中,不再需要 add。普通的二元运算符是自由函数,理论上可以很容易地转换为函数模板。例如:

template <typename Binary>
Binary operator+(Binary const &lhs, Binary const &rhs)
{
    return Binary{lhs} += rhs;
}

使用命名空间与否?

当使用函数模板 Binary operator+(Binary const &lhs, Binary const &rhs) 时,可能会遇到一个微妙而意外的复杂问题。考虑以下程序:

enum Values
{
    ZERO,
    ONE
};

template <typename Tp>
Tp operator+(Tp const &lhs, Tp const &rhs)
{
    return static_cast<Tp>(12);
}

int main()
{
    cout << (ZERO + ONE);
    // 显示 12
}

当运行这个程序时,它会显示值 12,而不是 1。

这种问题可以通过将运算符定义在它们自己的命名空间中来避免,但这会导致所有使用二元运算符的类也必须在那个命名空间中定义,这并不是一个很吸引人的限制。幸运的是,还有一个更好的替代方案:使用 CRTP(见第 22.12 节)。

CRTP 和定义运算符函数模板

当从模板类 Binops 继承类时,使用 CRTP(Curiously Recurring Template Pattern),运算符被定义为接受 Binops<Derived> 类型的参数,即基类接收其派生类作为模板参数。

因此,Binops 类以及额外的运算符都定义为期望 Binops<Derived> 类型的参数:

template <class Derived>
struct Binops
{
    Derived &operator+=(Derived const &rhs) &;
};

template <typename Derived>
Derived operator+(Binops<Derived> const &lhs, Derived const &rhs)
{
    return Derived{static_cast<Derived const &>(lhs)} += rhs;
}

// 对于 Binops<Derived> &&lhs 的类似实现

这样,一个从 Binops 继承的类,并且提供了一个绑定到 rvalue 引用对象的 operator+= 成员函数,也会自动提供所有其他的二元加法运算符:

class Derived: public Binops<Derived>
{
    // ...
public:
    Derived &&operator+=(Derived const &rhs) &&
};

除了一个运算符之外,其它运算符都可用。那个不可用的运算符是绑定到 lvalue 引用的复合加法运算符。由于其函数名与 Derived 类中的函数名相同,因此在用户级别不可见。

虽然通过为 Derived 类提供 using Binops<Derived>::operator+= 声明可以简单地解决这个问题,但这并不是一个很吸引人的解决方案,因为必须为 Derived 类中实现的每个二元运算符提供单独的 using 声明。

但是存在一个更有吸引力的解决方案。Wiebe-Marten Wijnja 提出了一个完全避免隐藏基类运算符的优美解决方案。他推测,绑定到 lvalue 引用的 operator+= 也可以定义为自由函数。在这种情况下,不使用继承,因此不会发生函数隐藏,从而可以避免使用 using 指令。

自由 operator+= 函数的实现如下:

template <class Derived>
Derived &operator+=(Binops<Derived> &lhs, Derived const &rhs)
{
    Derived tmp{ Derived{static_cast<Derived &>(lhs)} += rhs };
    tmp.swap(static_cast<Derived &>(lhs));
    return static_cast<Derived &>(lhs);
}

这种设计的灵活性可以进一步增强,一旦我们意识到右操作数不必是 Derived 类对象。例如,对于 operator<<,右操作数通常是一个 size_t 类型,用于指定移位的位数。实际上,右操作数的类型可以通过定义第二个模板类型参数来完全泛化,这个参数用于指定右操作数的类型。由 Derived 类来指定其 operator+=(或其他二元复合运算符)的参数类型,之后编译器将推导其余二元运算符的右操作数类型。以下是自由 operator+= 函数的最终实现:

template <class Derived, typename Rhs>
Derived &operator+=(Binops<Derived> &lhs, Rhs const &rhs)
{
    Derived tmp{ Derived{static_cast<Derived &>(lhs)} += rhs };
    tmp.swap(static_cast<Derived &>(lhs));
    return static_cast<Derived &>(lhs);
}

插入和提取运算符

类通常会定义重载的插入和提取运算符。由于没有“复合插入运算符”,因此前述设计方案不能用于重载这些运算符。相反,建议使用标准化的成员函数签名:

  • void insert(std::ostream &out) const 用于将对象插入到 ostream 中。
  • void extract(std::istream &in) const 用于从 istream 中提取对象。

由于这些函数仅用于插入和提取运算符,因此可以在派生类的私有接口中声明它们。为了避免将插入和提取运算符声明为 Derived 类的朋友,可以仅指定一个 friend Binops<Derived>。这允许 Binops<Derived> 定义私有的、内联的 iWrapeWrap 成员函数,仅调用 Derivedinsertextract 成员函数:

template <typename Derived>
inline void Binops<Derived>::iWrap(std::ostream &out) const
{
    static_cast<Derived const &>(*this).insert(out);
}

Binops<Derived> 然后声明插入和提取运算符为其朋友,这样这些运算符可以分别调用 iWrapeWrap。请注意,设计 Derived 类的软件工程师只需提供一个 friend Binops<Derived> 声明。以下是重载插入运算符的实现:

template <typename Derived>
std::ostream &operator<<(std::ostream &out, Binops<Derived> const &obj)
{
    obj.iWrap(out);
    return out;
}

这完成了对类模板 Binops 的基本覆盖,Binops 可以为任何派生自 Binops 的类提供二元运算符和插入/提取运算符的支持。最后,如本节开头所述,提供了一个完整实现了加法和插入运算符的类的示例,见于 C++ Annotations 源码档案中的 annotations/yo/concrete/examples/binopclasses.cc 文件。

区分 lvalue 和 rvalue 使用 operator

operator[] 的一个问题是它不能区分 lvalue 和 rvalue。常见的误解是认为 Type const &operator[](size_t index) const 用于 rvalue(因为对象未被修改),而 Type &operator[](size_t index) 用于 lvalue(因为返回值可以被修改)。然而,编译器仅通过 operator[] 调用的对象的 const 状态来区分这两个运算符。对于 const 对象,会调用前者,对于非 const 对象,总是调用后者,无论其作为 lvalue 还是 rvalue 使用。

能够区分 lvalue 和 rvalue 是非常有用的。例如,考虑一个支持 operator[] 的类,该类存储的数据类型非常难以复制。对于这种数据,可能使用了引用计数(例如,使用 shared_ptr)来避免不必要的复制。当 operator[] 作为 rvalue 使用时,不需要复制数据,但如果作为 lvalue 使用,则必须复制数据。

可以使用代理设计模式(Proxy Design Pattern)来区分 lvalue 和 rvalue。代理设计模式通过使用另一个类(代理类)来充当“真实事物”的替代者。代理类提供了数据本身无法提供的功能,例如区分其作为 lvalue 还是 rvalue 的使用。代理类可以用于许多情况下,真实数据不能或不应该被直接提供。例如,迭代器类型就是代理类的例子,它们在真实数据和使用数据的软件之间创建了一层。

在本节中,我们集中讨论使用 operator[] 作为 lvalue 和 rvalue 之间的区别。假设我们有一个类 Lines 用于存储文件中的行。它的构造函数期望一个流的名称,从中读取行,并提供一个非 const 的 operator[],可以用作 lvalue 或 rvalue(const 版本的 operator[] 被省略,因为它总是作为 rvalue 使用,不会造成混淆):

class Lines
{
    std::vector<std::string> d_line;
public:
    Lines(std::istream &in);
    std::string &operator[](size_t idx);
};

为了区分 lvalue 和 rvalue,我们必须找到 lvalue 和 rvalue 的区分特征。这些区分特征包括 operator=(总是作为 lvalue 使用)和转换运算符(总是作为 rvalue 使用)。我们可以让 operator[] 返回一个代理对象(Proxy),该对象能够区分其作为 lvalue 和 rvalue 的使用。

因此,Proxy 类需要定义 operator=(std::string const &other)(作为 lvalue 使用)和 operator std::string const &() const(作为 rvalue 使用):

是否需要更多运算符?std::string 类还提供了 operator+=,所以我们也许需要实现该运算符。普通字符也可以赋值给字符串对象(甚至使用其数字值)。由于字符串对象不能从普通字符构造,因此当右侧参数是字符时,operator=(std::string const &other) 不能使用类型提升。因此,可以考虑实现 operator=(char value)。这些附加运算符在当前实现中被省略,但“真实世界”中的代理类应考虑实现这些附加运算符。另一个细节是,Proxyoperator std::string const &() const 在使用 ostream 的插入运算符或 istream 的提取运算符时不会被使用,因为这些运算符是模板,不识别我们的 Proxy 类类型。因此,当需要流插入和提取时(可能确实需要),Proxy 必须定义其自己的重载插入和提取运算符。以下是实现重载插入运算符的代码,它将对象插入到流中:

inline std::ostream &operator<<(std::ostream &out, Lines::Proxy const &proxy)
{
    return out << static_cast<std::string const &>(proxy);
}

除了 Lines 类外,没有其他代码需要创建或复制 Proxy 对象。Proxy 的构造函数因此应当被设为私有,且 Proxy 可以将 Lines 声明为其朋友。实际上,ProxyLines 紧密相关,可以定义为嵌套类。修订后的 Lines 类中的 operator[] 不再返回 std::string,而是返回一个 Proxy 对象。以下是修订后的 Lines 类,包括其嵌套的 Proxy 类:

class Lines
{
    std::vector<std::string> d_line;
public:
    class Proxy;
    Proxy operator[](size_t idx);
    class Proxy
    {
        friend Proxy Lines::operator[](size_t idx);
        std::string &d_str;
        Proxy(std::string &str);
    public:
        std::string &operator=(std::string const &rhs);
        operator std::string const &() const;
    };
    Lines(std::istream &in);
};

Proxy 的成员函数非常轻量,通常可以实现为内联函数:

inline Lines::Proxy::Proxy(std::string &str)
    : d_str(str)
{}
inline std::string &Lines::Proxy::operator=(std::string const &rhs)
{
    return d_str = rhs;
}
inline Lines::Proxy::operator std::string const &() const
{
    return d_str;
}

成员 Lines::operator[] 也可以实现为内联函数:它仅返回一个初始化为与索引 idx 相关联的字符串的 Proxy 对象。

现在可以在程序中使用 Proxy 类。以下是一个示例,展示了如何将 Proxy 对象用作 lvalue 或 rvalue。表面上看,Lines 对象的行为不会与使用原始实现的 Lines 对象不同,但通过在 Proxy 的成员中添加标识 cout 语句,可以显示 operator[] 在作为 lvalue 或 rvalue 使用时的不同:

int main()
{
    std::ifstream in("lines.cc");
    Lines lines(in);

    std::string s = lines[0];        // rvalue 使用
    lines[0] = s;                    // lvalue 使用
    std::cout << lines[0] << '\n';   // rvalue 使用
    lines[0] = "hello world";        // lvalue 使用
    std::cout << lines[0] << '\n';   // rvalue 使用
}

实现 reverse_iterator

在第22.14.1节中,讨论了迭代器和反向迭代器的构造。在那一节中,迭代器作为一个嵌套类在从指向字符串的指针的向量派生的类中构造。这个嵌套迭代器类处理了对存储在向量中的指针的解引用。这使我们能够对指向向量元素的字符串进行排序,而不是对指针进行排序。

这种方法的一个缺点是实现迭代器的类紧密地绑定到派生类,因为迭代器类被实现为嵌套类。如果我们想要为任何存储指针的容器类提供一个处理指针解引用的迭代器怎么办?

在这一节中,我们讨论了一种与之前(嵌套类)方法不同的变体。这里,迭代器类被定义为一个类模板,不仅参数化了容器元素所指向的数据类型,还参数化了容器的迭代器类型。我们集中开发一个 RandomPtrIterator 类,因为它是最复杂的迭代器类型。

我们的类名为 RandomPtrIterator,表明它是一个在指针值上操作的随机访问迭代器。类模板定义了三个模板类型参数:

  • 第一个参数指定了派生类类型(Class)。像以前一样,RandomPtrIterator 的构造函数是私有的。因此,需要友元声明以允许客户端类构造 RandomPtrIterator。然而,不能使用友元类 Class,因为模板参数类型不能在友元类声明中使用。但这不是大问题,因为并不是每个客户端类的成员都需要构造迭代器。实际上,只有 Classbeginend 成员需要构造迭代器。使用模板的第一个参数,可以为客户端的 beginend 成员指定友元声明。
  • 第二个模板参数参数化了容器的迭代器类型(BaseIterator)。
  • 第三个模板参数指示指针指向的数据类型(Type)。

RandomPtrIterator 具有一个私有数据成员 BaseIterator。以下是类接口和构造函数的实现:

#include <iterator>
#include <compare>

template <typename Class, typename BaseIterator, typename Type>
struct RandomPtrIterator;

#define PtrIterator RandomPtrIterator<Class, BaseIterator, Type>
#define PtrIteratorValue RandomPtrIterator<Class, BaseIterator, value_type>

template <typename Class, typename BaseIterator, typename Type>
bool operator==(PtrIterator const &lhs, PtrIterator const &rhs);

template <typename Class, typename BaseIterator, typename Type>
auto operator<=>(PtrIterator const &lhs, PtrIterator const &rhs);

template <typename Class, typename BaseIterator, typename Type>
int operator-(PtrIterator const &lhs, PtrIterator const &rhs);

template <typename Class, typename BaseIterator, typename Type>
struct RandomPtrIterator
{
    using iterator_category = std::random_access_iterator_tag;
    using difference_type   = std::ptrdiff_t;
    using value_type        = Type;
    using pointer           = value_type *;
    using reference         = value_type &;

    friend PtrIterator Class::begin();
    friend PtrIterator Class::end();

    friend bool operator==<>(RandomPtrIterator const &lhs,
                             RandomPtrIterator const &rhs);
    friend auto operator<=><>(RandomPtrIterator const &lhs,
                              RandomPtrIterator const &rhs);
    friend int operator-<>(RandomPtrIterator const &lhs,
                           RandomPtrIterator const &rhs);
private:
    BaseIterator d_current;

public:
    int operator-(RandomPtrIterator const &rhs) const;
    RandomPtrIterator operator+(int step) const;
    value_type &operator*() const;
    RandomPtrIterator &operator--();
    RandomPtrIterator operator--(int);
    RandomPtrIterator &operator++();
    RandomPtrIterator operator++(int);
    RandomPtrIterator operator-(int step) const;
    RandomPtrIterator &operator-=(int step);
    RandomPtrIterator &operator+=(int step);
    value_type *operator->() const;

private:
    RandomPtrIterator(BaseIterator const &current);
};

template <typename Class, typename BaseIterator, typename value_type>
PtrIteratorValue::RandomPtrIterator(BaseIterator const &current)
:
    d_current(current)
{}

从其友元声明来看,我们可以看到,Classbeginend 成员返回一个 RandomPtrIterator 对象,并且这些成员被授予了访问 RandomPtrIterator 的私有构造函数的权限。这正是我们想要的。Classbeginend 成员被声明为绑定友元。

RandomPtrIterator 的其余成员都是公共的。由于 RandomPtrIterator 是第22.14.1节中嵌套类迭代器的通用化,因此重新实现所需的成员函数很简单,只需要将迭代器改为 RandomPtrIterator 并将 std::string 改为 Type。例如,定义在类迭代器中的 operator< 现在可以实现为:

template <typename Class, typename BaseIterator, typename Type>
inline auto operator<=>(PtrIterator const &lhs, PtrIterator const &rhs)
{
    return **lhs.d_current <=> **rhs.d_current;
}

其他一些示例:operator*,在类迭代器中定义为:

inline std::string &StringPtr::iterator::operator*() const
{
    return **d_current;
}

现在可以实现为:

template <typename Class, typename BaseIterator, typename value_type>
value_type &PtrIteratorValue::operator*() const
{
    return **d_current;
}

前缀和后缀自增运算符现在可以实现为:

template <typename Class, typename BaseIterator, typename value_type>
PtrIteratorValue &PtrIteratorValue::operator++()
{
    ++d_current;
    return *this;
}

template <typename Class, typename BaseIterator, typename value_type>
PtrIteratorValue PtrIteratorValue::operator++(int)
{
    return RandomPtrIterator(d_current++);
}

其余成员可以相应实现,它们的实际实现留给读者作为练习(当然,也可以从 cplusplus.yo.zip 存档中获取)。

重新实现第22.14.1节中开发的 StringPtr 类也不难。除了包含定义 RandomPtrIterator 类模板的头文件外,仅需要进行一个修改:其迭代器使用声明现在必须与 RandomPtrIterator 相关联。以下是完整的类接口和类的内联成员定义:

#ifndef INCLUDED_STRINGPTR_H_
#define INCLUDED_STRINGPTR_H_

#include <vector>
#include <string>
#include "iterator.h"

class StringPtr: public std::vector<std::string *>
{
public:
    using iterator =
            RandomPtrIterator
            <
                StringPtr,
                std::vector<std::string *>::iterator,
                std::string
            >;

    using reverse_iterator = std::reverse_iterator<iterator>;

    iterator begin();
    iterator end();
    reverse_iterator rbegin();
    reverse_iterator rend();
};

inline StringPtr::iterator StringPtr::begin()
{
    return iterator(this->std::vector<std::string *>::begin());
}

inline StringPtr::iterator StringPtr::end()
{
    return iterator(this->std::vector<std::string *>::end());
}

inline StringPtr::reverse_iterator StringPtr::rbegin()
{
    return reverse_iterator(end());
}

inline StringPtr::reverse_iterator StringPtr::rend()
{
    return reverse_iterator(begin());
}

#endif

将修改后的 StringPtr 头文件包含到第22.14.2节中给出的程序中,会导致程序行为与其早期版本一致。在这种情况下,StringPtr::beginStringPtr::end 返回的迭代器对象是由模板定义构造的。

使用 bisonc++flexc++

下面的示例探讨了使用解析器和扫描器生成器生成 C++ 源代码的特殊情况。当程序的输入超过一定复杂度时,使用生成器生成实际的输入识别代码变得很有吸引力。

本节及后续部分的示例假设读者了解如何使用扫描器生成器 flex 和解析器生成器 bisonbisonflex 都有详细的文档。bisonflex 的前身 yacclex 在几本书中有描述,例如 O’Reilly 的《lex & yacc》。

扫描器和解析器生成器也作为免费软件提供。bisonflex 通常是软件分发的一部分,或者可以从 ftp://prep.ai.mit.edu/pub/non-gnu 获得。flex 创建一个 C++ 类,当指定 %option c++ 时。

解析器生成器程序 bison 可用。在90年代初,Alain Coetmeur(coetmeur@icdc.fr)创建了一个 C++ 变体(bison++),生成一个解析器类。虽然 bison++ 生成的代码可以在 C++ 程序中使用,但它也显示了许多更倾向于 C 语言的特性。在2005年1月,我重写了 Alain 的 bison++ 程序,结果是 bisonc++ 的原始版本。然后,在2005年5月完成了 bisonc++ 解析器生成器的完整重写(版本号 0.98)。可以从 https://fbb-git.gitlab.io/bisoncpp/ 下载当前版本的 bisonc++。各种架构的二进制版本也可以作为 Debian 软件包提供(包括 bisonc++ 的文档)。

bisonc++ 创建的解析器类比 bison++ 更干净。特别是,它将解析器类派生自一个基类,基类包含解析器的令牌和类型定义以及所有不应由程序员(重新)定义的成员函数。因此,生成的解析器类非常小,仅声明实际由程序员定义的成员(以及由 bisonc++ 本身生成的一些其他成员,实现在解析器的 parse() 成员)。一个默认未实现的成员是 lex,它生成下一个词法令牌。当使用指令 %scanner(见第25.6.2.1节)时,bisonc++ 会为该成员生成一个标准实现;否则,必须由程序员实现。

在2012年初,程序 flexc++http://flexcpp.org/)达到了其初始发布版本。bisonc++ 是 Debian Linux 发行版的一部分。

Jean-Paul van Oosten(jp@jpvanoosten.nl)和 Richard Berendsen(richardberendsen@xs4all.nl)于2008年启动了 flexc++ 项目,最终程序由 Jean-Paul 和我在2010年至2012年间完成。

这些 C++ 注释的部分内容专注于 bisonc++ 作为我们的解析器生成器和 flexc++ 作为我们的词法扫描器生成器。C++ 注释的先前版本使用了 flex 作为扫描器生成器。

使用 flexc++bisonc++ 可以生成基于类的扫描器和解析器。这种方法的优势在于,扫描器和解析器的接口通常比不使用类接口的情况要干净。此外,类允许我们摆脱大多数甚至所有全局变量,使得在一个程序中使用多个解析器变得更加容易。

下面开发了两个示例程序。第一个示例仅使用 flexc++。生成的扫描器监控从几个部分组成的文件的生成。该示例重点关注词法扫描器和在处理信息时切换文件。第二个示例使用 flexc++bisonc++ 生成一个扫描器和一个解析器,将标准算术表达式转换为其后缀表示法,这在编译器生成的代码和 HP 计算器中常用。在第二个示例中,重点主要是 bisonc++ 和在生成的解析器中组成扫描器对象。

使用 flexc++ 创建扫描器

本节开发的词法扫描器用于监控从几个子文件生成的文件。设置如下:输入语言定义 #include 指令,随后是一个文本字符串,指定应在 #include 位置包含的文件(路径)。

为了避免与当前示例无关的复杂性,#include 语句的格式被限制为 #include <filepath> 的形式。尖括号之间指定的文件应在 filepath 所指示的位置可用。如果文件不可用,程序将在发出错误消息后终止。

程序以一个或两个文件名参数启动。如果程序以一个文件名参数启动,输出将写入标准输出流 cout。否则,输出将写入作为程序第二个参数给出的流。

程序定义了一个最大嵌套深度。一旦超过该深度,程序将在发出错误消息后终止。在这种情况下,将打印指示哪个文件被包含的文件名堆栈。

程序的一个附加功能是忽略(标准 C++)注释行。注释行中的 #include 指令也被忽略。

程序创建分为五个主要步骤:

  1. 首先,构建文件词法分析器,包含输入语言的规范。
  2. 词法分析器的规范中,推导出 Scanner 类的要求。Scanner 类从由 flexc++ 生成的基类 ScannerBase 派生。
  3. 接下来,构建 main 函数。创建一个 Scanner 对象,检查命令行参数。如果成功,则调用扫描器的 lex 成员生成程序的输出。
  4. 现在,已指定程序的全局设置,实现各种类的成员函数。
  5. 最后,编译和链接程序。

派生类 Scanner

匹配正则表达式规则(lex)的函数是 Scanner 类的一个成员。由于 ScannerScannerBase 派生,它可以访问 ScannerBase 的所有受保护成员,这些成员执行词法扫描器的正则表达式匹配算法。

在查看正则表达式本身时,请注意我们需要规则来识别注释、#include 指令和所有剩余字符。这些都是相当标准的做法。当检测到 #include 指令时,扫描器会解析该指令,这也是常见的做法。

我们的词法扫描器执行以下任务:

  • 通常,预处理指令由词法扫描器而非解析器进行分析;
  • 扫描器 使用一个迷你扫描器从指令中提取文件名,如果提取失败,则抛出异常;
  • 如果 文件名成功提取,处理将切换到下一个流,同时控制最大嵌套深度;
  • 一旦 当前文件的末尾被到达,处理将自动返回到上一个文件,恢复之前的文件名和行号。如果所有文件都已处理完毕,扫描器返回 0。

词法扫描器规范文件

词法扫描器规范文件的组织方式类似于在 C 上下文中使用的 flex 规范文件。然而,在 C++ 上下文中,flexc++ 创建了一个 Scanner 类,而不仅仅是一个扫描器函数。

flexc++ 的规范文件由两个部分组成:

  1. 第一个部分是 flexc++ 的符号区域,用于定义符号,例如迷你扫描器或选项。以下选项是建议的:

    • %debug:将调试代码包含到 flexc++ 生成的代码中。调用成员函数 setDebug(true) 可以在运行时激活这些调试代码。激活后,关于匹配过程的信息将写入标准输出流。调用成员函数 setDebug(false) 后,调试代码的执行将被抑制。
    • %filenames:定义 flexc++ 生成的类头文件的基本名称。默认情况下,使用类名(默认情况下为 Scanner)。

    以下是规范文件的符号区域示例:

    %filenames scanner
    %debug
    %max-depth 3
    
    %x      comment
    %x      include
    
  2. 第二部分是规则区,其中定义了正则表达式及其相关操作。在这里开发的示例中,词法分析器应该将信息从标准输入流(std::cin)复制到标准输出流(std::cout)。为此,可以使用预定义的宏 ECHO。以下是规则:

    %%
    // 注释规则:注释被忽略。
    "//".*                  // 忽略行尾注释
    "/*"                    begin(StartCondition__::comment);
    <comment>{
        .|\n                // 忽略标准 C 注释中的所有字符
        "*/"                begin(StartCondition__::INITIAL);
    }
    
    // 文件切换:#include <filepath>
    #include[ \t]+"<"      begin(StartCondition__::include);
    <include>{
        [^ \t>]+            d_nextSource = matched();
        ">"[ \t]*\n         switchSource();
        .|\n                throw runtime_error("Invalid include statement");
    }
    
    // 默认规则:将其他所有内容回显到 std::cout
    .|\n                    echo();
    

实现 Scanner

Scanner 由 flexc++ 生成一次。这个类可以访问其基类 ScannerBase 定义的几个成员。其中一些成员具有公共访问权限,可以被 Scanner 类外部的代码使用。这些成员在 flexc++(1) 手册中有详细文档,读者可以查阅该手册以获取更多信息。

我们的扫描器执行以下任务:

  • 匹配正则表达式,忽略注释,并将匹配的文本写入标准输出流;
  • 切换到其他文件,并在一个文件完全处理后返回到之前的文件,一旦第一个输入文件处理完毕,结束词法扫描。

输入中的 #include 语句允许扫描器提取要继续扫描的文件名。这个文件名被存储在局部变量 d_nextSource 中,并且一个成员 stackSource 处理切换到下一个源。其他操作则不需要。推送和弹出输入文件是通过扫描器的成员函数 pushStreampopStream 处理的,这些函数由 flexc++ 提供。因此,Scanner 的接口只需要额外声明一个函数:switchSource

流的切换是这样处理的:一旦扫描器从 #include 指令中提取了文件名,通过 switchSource 实现切换到另一个文件。这个成员函数调用 pushStream,这是由 flexc++ 定义的,用于将当前输入流压入栈中并切换到存储在 d_nextSource 中的流。这也结束了 include 迷你扫描器,因此调用 begin(StartCondition__::INITIAL) 将扫描器返回到默认的扫描模式。以下是其源代码:

#include "scanner.ih"

void Scanner::switchSource()
{
    pushStream(d_nextSource);
    begin(StartCondition__::INITIAL);
}

成员 pushStream 由 flexc++ 定义,处理所有必要的检查,如果文件无法打开或文件栈溢出,会抛出异常。

执行词法扫描的成员函数由 flexc++ 在 Scanner::lex 中定义,该成员函数可以被代码调用,以处理扫描器返回的标记。

使用 Scanner 对象

使用我们的 Scanner 的程序非常简单。它期望一个文件名来指定开始扫描的起点。

程序首先检查参数的数量。如果提供了至少一个参数,则将该参数传递给 Scanner 的构造函数,同时传递第二个参数 “-”,表示输出应写入标准输出流。

如果程序接收到多个参数,则会将调试输出写入标准输出流,这些调试信息详细记录了词法扫描器的操作。

接下来,调用 Scannerlex 成员函数。如果发生任何错误,将抛出 std::exception,并在 main 函数的 try 块的 catch 子句中捕获。以下是程序的源代码:

#include "lexer.ih"

int main(int argc, char **argv)
try
{
    if (argc == 1)
    {
        cerr << "Filename argument required\n";
        return 1;
    }

    Scanner scanner(argv[1], "-");
    scanner.setDebug(argc > 2);
    return scanner.lex();
}
catch (exception const &exc)
{
    cerr << exc.what() << '\n';
    return 1;
}

该程序的逻辑如下:

  1. 检查传入的参数数量。如果没有传递文件名参数,则输出错误信息并返回 1。
  2. 创建一个 Scanner 对象,传入文件名和输出流标识符 “-”。
  3. 如果传递了多个参数,则启用调试模式。
  4. 调用 scanner.lex() 执行扫描操作。如果发生异常,则捕获并输出异常信息,并返回 1。

构建程序

最终程序的构建分为两个步骤。以下步骤适用于已安装 flexc++ 和 GNU C++ 编译器 g++ 的 Unix 系统:

  1. 首先,使用 flexc++ 创建词法扫描器的源代码。可以使用以下命令:

    flexc++ lexer
    
  2. 接下来,编译和链接所有源代码

    g++ -Wall *.cc
    

flexc++ 可以从 https://fbb-git.gitlab.io/flexcpp/ 下载,并且需要 bobcat 库,可以从 http://fbb-git.gitlab.io/bobcat/ 下载。

使用 bisonc++flexc++

当输入语言的复杂度超过一定水平时,通常会使用解析器来控制语言的复杂性。在这种情况下,可以使用解析器生成器生成代码,以验证输入的语法正确性。词法扫描器(最好与解析器组合使用)提供输入的块,称为标记(tokens)。然后,解析器处理由词法扫描器生成的一系列标记。

开发同时使用解析器和扫描器的程序时,从语法开始是一个良好的起点。语法定义了词法扫描器可以返回的一组标记(在下面称为扫描器)。

最后,提供辅助代码来“填补空白”:解析器和扫描器执行的操作通常不会在语法规则或词法正则表达式中直接指定,而是应该在成员函数中实现,这些函数由解析器的规则调用或与扫描器的正则表达式相关联。

在前一节中,我们看到了由 flexc++ 生成的 C++ 类。在本节中,我们将重点关注解析器。解析器可以从由程序 bisonc++ 处理的语法规范文件生成。bisonc++ 所需的语法规范文件类似于由 bison 处理的文件(或者 bison++bisonc++ 的前身,由 Alain Coetmeur 在九十年代初编写)。

在本节中,我们开发了一个程序,将中缀表达式(即二元操作符在操作数之间书写)转换为后缀表达式(即操作符在操作数之后书写)。此外,还将一元操作符 - 从前缀表示法转换为后缀形式。一元 + 操作符被忽略,因为它不需要进一步处理。总之,我们的小计算器是一个微型编译器,将数值表达式转换为类似汇编语言的指令。

我们的计算器识别一组基本操作符:乘法、加法、括号和一元减号。我们将区分实数和整数,以说明 bison-like 语法规范中的一个细微之处。就这些了。本节的目的是展示如何构建一个同时使用解析器和词法扫描器的 C++ 程序,而不是构建一个功能全面的计算器。

接下来的章节将开发 bisonc++ 的语法规范。然后,将指定扫描器的正则表达式。随后,将构建最终程序。

bisonc++ 规范文件

bisonc++ 所需的语法规范文件类似于 bison 所需的文件。不同之处在于生成的解析器是一个类。我们的计算器区分实数和整数,并支持基本的算术操作符。

使用 bisonc++ 的步骤如下:

  • 如同以往,定义语法规则。使用 bisonc++ 的语法规则与 bison 的语法规则基本相同。
  • 在定义语法规则和(通常)一些声明后,bisonc++ 可以生成定义解析器类及其 parse 成员函数实现的文件。
  • 除了 parse 函数正常工作所需的成员外,所有类成员(如 lex 成员)必须单独实现。这些成员也应在解析器类的头文件中声明。lex 成员由 parse 调用以获取下一个可用的标记。然而,bisonc++ 提供了一个标准实现的 lex 函数。error(char const *msg) 成员函数提供了一个简单的默认实现,程序员可以根据需要进行修改。当 parse 检测到(语法)错误时,会调用 error 成员函数。
  • 现在可以在程序中使用解析器了。一个非常简单的示例是:
    int main()
    {
        Parser parser;
        return parser.parse();
    }
    

bisonc++ 规范文件分为两个部分:

  • 声明部分:在此部分声明了 bison 的标记和运算符的优先级规则。此外,bisonc++ 还支持几种新的声明。这些新声明在下文中进行了讨论。
  • 规则部分:语法规则定义了语法。这部分与 bison 所需的部分相同,尽管某些在 bison 和 bison++ 中可用的成员在 bisonc++ 中已废弃,而其他成员可以在更广泛的上下文中使用。例如,ACCEPTABORT 可以从任何解析器动作块中调用,以终止解析过程。熟悉 bison 的读者可能会注意到,没有了头文件部分。头文件部分是 bison 用来提供必要声明的,以便编译器可以编译 bison 生成的 C 函数。在 C++ 中,声明是类定义的一部分或已被使用。因此,生成 C++ 类及其成员函数的解析器生成器不再需要头文件部分。
声明部分

声明部分包含多个声明集,其中包括语法中使用的所有标记的定义以及数学运算符的优先级和结合性。此外,还可以在此处使用几个新的重要规范,这些规范在 bisonc++ 中独有,相关内容在下面进行了讨论。

读者可以参考 bisonc++ 的手册页获取完整描述。

  • %baseclass-preinclude header
    使用 header 作为路径名,指定要在解析器基类头文件中预先包含的文件。在某些情况下,基类头文件引用的类型可能尚未被知道。例如,使用 %union 时,可能使用 std::string * 字段。由于编译器处理基类头文件时可能还不知道 std::string 类,因此需要一种方式来通知编译器这些类和类型。建议的做法是使用预包含头文件来声明所需的类型。默认情况下,header 用双引号括起来(例如,#include "header")。当参数用尖括号括起来时,则包含 #include <header>。在后一种情况下,可能需要使用引号来逃避解释(例如,使用 -H '<header>')。

  • %filenames header
    定义所有生成文件的通用名称,除非被特定名称覆盖。默认情况下,生成的文件使用类名作为通用文件名。

  • %scanner header
    使用 header 作为路径名,指定要在解析器类头文件中预先包含的文件。此文件应定义一个 Scanner 类,提供一个 int lex() 成员函数,从输入流中生成下一个要由 bisonc++ 生成的解析器分析的标记。当使用此选项时,解析器的 int lex() 成员函数将被预定义为(假设使用默认的解析器类名 Parser):

    inline int Parser::lex()
    {
        return d_scanner.lex();
    }
    

    并且一个 Scanner d_scanner 对象被合成到解析器中。d_scanner 对象由其默认构造函数构造。如果需要另一个构造函数,可以在用 bisonc++ 生成默认解析器类头文件后,提供一个适当的(重载)解析器构造函数。默认情况下,header 用双引号括起来(例如,#include "header")。当参数用尖括号括起来时,则包含 #include <header>

  • %stype typename
    标记的语义值的类型。规范 typename 应该是一个非结构化类型的名称(例如,size_t)。默认情况下为 int。参见 bison 中的 YYSTYPE。如果使用了 %union 规范,则不应使用 %stype。在解析器类中,此类型可以用作 STYPE

  • %union union-definition
    行为与 bison 声明相同。与 bison 一样,这会为解析器的语义类型生成一个联合体。联合体类型命名为 STYPE。如果没有声明 %union,可以使用 %stype 声明定义一个简单的堆栈类型。如果未使用 %stype 声明,则使用默认的堆栈类型(int)。

    示例 %union 声明:

    %union
    {
        int i;
        double d;
    };
    

    在 C++11 之前的代码中,联合体不能包含对象作为其字段,因为在创建联合体时无法调用构造函数。这意味着字符串不能作为联合体的成员。然而,string * 是可能的联合体成员。还可能使用无限制的联合体(参见第 9.9 节),使类类型对象作为字段。

    顺便提一下:扫描器不必知道这样的联合体。它可以简单地通过其 matched 成员函数将扫描的文本传递给解析器。例如,使用如下语句:

    $$.i = A2x(d_scanner.matched());
    

    将匹配的文本转换为适当类型的值。

    可以将标记和非终结符与联合体字段关联。这是强烈建议的,因为它可以防止类型不匹配,因为编译器可以检查类型正确性。同时,可以使用 bison 特定的变量 $$$1$2 等,而不是完整的字段规范(如 $$.i)。例如:

    %token <i> INT // 标记关联(已弃用,见下文)
    <d> DOUBLE
    %type <i> intExpr // 非终结符关联
    %type <d> doubleExpr
    

    在这里,%type 规范将 intExpr 规则的值(见下一节)与语义值联合体的 i 字段关联,将 doubleExpr 的值与 d 字段关联。这种方法确实相当复杂,因为每个支持的联合体类型都必须包含表达式规则。也可以使用多态语义值,这在 bisonc++ 用户指南中有详细介绍。

语法规则

语法规则和动作按常规指定。以下是我们的小计算器的语法规则。虽然规则比较多,但它们展示了 bisonc++ 提供的各种功能。特别注意,没有任何动作块需要多于一行代码。这使得语法规则简单,从而增强了其可读性和可理解性。即使定义解析器正确终止的规则(即行规则中的空行)也只使用一个名为 done 的成员函数。该函数的实现很简单,但值得注意的是,它调用了 Parser::ACCEPT,表明 ACCEPT 可以通过生产规则的动作块间接调用。以下是语法的生成规则:

  lines:
        lines
        line
    |
        line
    ;

    line:
        intExpr
        '\n'
        {
            display($1);
        }
    |
        doubleExpr
        '\n'
        {
            display($1);
        }
    |
        '\n'
        {
            done();
        }
    |
        error
        '\n'
        {
            reset();
        }
    ;

    intExpr:
        intExpr '*' intExpr
        {
            $$ = exec('*', $1, $3);
        }
    |
        intExpr '+' intExpr
        {
            $$ = exec('+', $1, $3);
        }
    |
        '(' intExpr ')'
        {

            $$ = $2;
        }
    |
        '-' intExpr         %prec UnaryMinus
        {
            $$ = neg($2);
        }
    |
        INT
        {
            $$ = convert<int>();
        }
    ;

    doubleExpr:
        doubleExpr '*' doubleExpr
        {
            $$ = exec('*', $1, $3);
        }
    |
        doubleExpr '*' intExpr
        {
            $$ = exec('*', $1, d($3));
        }
    |
        intExpr '*' doubleExpr
        {
            $$ = exec('*', d($1), $3);
        }
    |
        doubleExpr '+' doubleExpr
        {
            $$ = exec('+', $1, $3);
        }
    |
        doubleExpr '+' intExpr
        {
            $$ = exec('+', $1, d($3));
        }
    |
        intExpr '+' doubleExpr
        {
            $$ = exec('+', d($1), $3);
        }
    |
        '(' doubleExpr ')'
        {
            $$ = $2;
        }
    |
        '-' doubleExpr         %prec UnaryMinus
        {

            $$ = neg($2);
        }
    |
        DOUBLE
        {
            $$ = convert<double>();
        }
    ;

解析器的头文件

这个语法用于实现一个简单的计算器,支持对整数和实数值进行取负、加法和乘法运算,并且可以通过括号来覆盖标准的优先级规则。语法展示了带类型的非终结符符号:doubleExpr 关联到实数(double)值,intExpr 关联到整数值。优先级和类型关联在解析器的定义部分中定义。

parser.h 文件

bisonc++ 生成多个文件,其中包括定义解析器类的文件。函数从生产规则的动作块中调用,通常是解析器的成员函数。这些成员函数必须被声明和定义。一旦 bisonc++ 生成了定义解析器类的头文件,该头文件不会自动重写,允许程序员根据需要向解析器类中添加新的成员。以下是我们的小计算器使用的 parser.h 文件:

#ifndef Parser_h_included
#define Parser_h_included

#include <iostream>
#include <sstream>
#include <bobcat/a2x>

#include "parserbase.h"
#include "../scanner/scanner.h"

#undef Parser
class Parser: public ParserBase
{
    std::ostringstream d_rpn;  // 用于记录逆波兰表示法的字符串流
    // $insert scannerobject
    Scanner d_scanner;  // 扫描器对象

    public:
        int parse();  // 解析函数

    private:
        template <typename Type>
        Type exec(char c, Type left, Type right);  // 执行操作(加法或乘法)

        template <typename Type>
        Type neg(Type op);  // 计算负值

        template <typename Type>
        Type convert();  // 转换函数,将扫描器匹配的文本转换为指定类型

        void display(int x);  // 显示整数
        void display(double x);  // 显示实数
        void done() const;  // 解析完成后的处理
        void reset();  // 重置解析器状态
        void error(char const *msg);  // 错误处理
        int lex();  // 从扫描器获取下一个标记
        void print();  // 打印(暂未实现)

        static double d(int i);  // 静态函数,将整数转换为实数

    // 支持 parse() 函数的辅助函数:

        void executeAction(int d_ruleNr);  // 执行动作
        void errorRecovery();  // 错误恢复
        int lookup(bool recovery);  // 查找标记
        void nextToken();  // 获取下一个标记
        void print__();  // 打印(内部使用)

};

// 静态函数实现:将整数转换为实数
inline double Parser::d(int i)
{
    return i;
}

// 执行操作:加法或乘法
template <typename Type>
Type Parser::exec(char c, Type left, Type right)
{
    d_rpn << " " << c << " ";
    return c == '*' ? left * right : left + right;
}

// 计算负值
template <typename Type>
Type Parser::neg(Type op)
{
    d_rpn << " n ";
    return -op;
}

// 转换函数:将扫描器匹配的文本转换为指定类型
template <typename Type>
Type Parser::convert()
{
    Type ret = FBB::A2x(d_scanner.matched());
    d_rpn << " " << ret << " ";
    return ret;
}

// 错误处理
inline void Parser::error(char const *msg)
{
    std::cerr << msg << '\n';
}

// 从扫描器获取下一个标记
inline int Parser::lex()
{
    return d_scanner.lex();
}

// 打印(暂未实现)
inline void Parser::print()
{}

#endif

这个头文件定义了一个解析器类 Parser,继承自 ParserBase。它包含了处理解析过程所需的各种成员函数和数据成员,例如处理不同类型数据的模板函数、错误处理和结果显示等。

flexc++ 规范文件

用于计算器的 flexc++ 规范文件很简单:忽略空白字符,单个字符返回,并且将数值返回为 Parser::INTParser::DOUBLE 标记。由于计算器是一个与用户积极交互的程序,因此使用了 flexc++ 指令 %interactive

以下是完整的 flexc++ 规范文件:

%interactive
%filenames scanner

%%

[ \t]                       // 忽略空白字符

[0-9]+                      return Parser::INT;  // 返回整数标记

"."[0-9]+                   |
[0-9]+"."[0-9]*             return Parser::DOUBLE;  // 返回双精度浮点标记

.|\n                        return matched()[0];  // 返回单个字符或换行符

这个文件定义了 flexc++ 扫描器的行为:

  • 忽略空白字符(空格和制表符)。
  • 将整数值返回为 Parser::INT 标记。
  • 将浮点数值返回为 Parser::DOUBLE 标记。
  • 返回所有其他单个字符或换行符。

构建程序

计算器使用 bisonc++flexc++ 构建。以下是计算器的主函数实现:

#include "parser/parser.h"

using namespace std;

int main()
{
    Parser parser;

    cout << "Enter (nested) expressions containing ints, doubles, *, + and "
            "unary -\n"
            "operators. Enter an empty line to stop.\n";

    return parser.parse();
}

解析器的文件 parse.ccparserbase.h 是通过以下命令生成的:

bisonc++ grammar

文件 parser.h 仅创建一次,以允许开发者在需要时向 Parser 类中添加成员。

程序 flexc++ 用于创建词法分析器:

flexc++ lexer

在 Unix 系统上,可以使用如下命令编译和链接主程序及由扫描器和解析器生成器生成的源代码:

g++ -Wall -o calc *.cc -lbobcat -s

此示例使用了 A2x 类,它是 bobcat 库的一部分(参见第 26.6.1.5 节)。bobcat 库可以在提供 bisonc++flexc++ 的系统上获得。bisonc++ 可以从 http://fbb-git.gitlab.io/bisoncpp/ 下载。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值