专注、坚持

C/C++ 中的位域与共用体

2019.08.17 by kingcos
Date Notes Env
2019-08-17 首次提交 clang++、macOS 10.14.4

0

Preface

C/C++ 中有许多「奇技淫巧」来让开发者能够以效率更高的方式使用内存,这也是一些高级语言着力去避讳的一点,但这种能够直面内存细节的「踏实感」让我个人觉得非常安心。本文将简单涉及 C/C++ 中的位域(Bit Field)与共用体(Union)的概念。

位域

布尔类型

对于布尔(Boolean)类型,我们都知道其只有两种值:逻辑真或逻辑假。在 C 语言中,其实并没有严格意义上的布尔类型;而在 C++ 中,则诞生了专用的 bool 类型:

#include <iostream>

using namespace std;

int main()
{
    bool foo = true;
    bool bar = 0;

    cout << foo << endl; // 1
    cout << bar << endl; // 0

    cout << sizeof(foo) << endl; // 1
    cout << sizeof(bar) << endl; // 1

    return 0;
}

如上,在 C++ 中,true 和非 0 代表布尔类型中的逻辑真,false0 代表逻辑假。bool 类型的值通常在 C++ 中占用一个字节长度(「通常」代表对于不同的编译器结果可能并非完全一致)。

Why

熟悉计算机存储单位的同学都应该知道,bit 正是存储 01 的单位,那么为什么 bool 要占用一字节而不是一位的内存空间呢?

Because the CPU can’t address anything smaller than a byte.

– Paul Tomblin, StackOverflow

原因是:字节是 CPU 可处理的最小单位。而一个字节为 8 位,当我们使用到多个相关的 bool 类型时,有没有方法可以优化内存的占用呢?

What

位域(也称位段),是一种数据结构,即可以将数据以位为单位存储,节省内存空间。

我们尝试定义一个 FooBarBaz 位域结构,其中 foo & bar 各占用 1 位,baz 占用 2 位。由于位数之和并没有占满 8 位,整个结构的大小使用一个字节就足够了:

struct
{
    char foo : 1;
    char bar : 1;
    char baz : 2;
} FooBarBaz;

int main()
{
    cout << sizeof(FooBarBaz) << endl; // 1

    return 0;
}

需要注意的是,在上述的位域结构里,这里我们使用了 char 类型。在 C99 标准中,位域被解释为特定位数的有符号或无符号整型,或者一些其它由实现定义的类型。因此其实这里的类型多是用作描述整个位域结构的大小,而非定义位中存储的数据类型。

所以在小端模式的机器上,char 类型的一个字节中,假设为 0b 0000 0110foo0bar1baz01(从右往左)。

How

那么到底如何使用位域来存储多个 bool 值呢?

class Demo
{
    struct
    {
        char foo : 1;
        char bar : 1;
        char baz : 1;
    } fooBarBaz;

public:
    void setFoo(bool foo)
    {
        fooBarBaz.foo = foo;
    }

    bool getFoo()
    {
        return fooBarBaz.foo;
    }

    void setBar(bool bar)
    {
        fooBarBaz.bar = bar;
    }

    bool getBar()
    {
        return fooBarBaz.bar;
    }

    void setBaz(bool baz)
    {
        fooBarBaz.baz = baz;
    }

    bool getBaz()
    {
        return fooBarBaz.baz;
    }
};

int main()
{
    Demo d = Demo();
    d.setFoo(true);
    d.setBar(false);
    d.setBaz(true);

    cout << d.getFoo() << endl; // 1
    cout << d.getBar() << endl; // 0
    cout << d.getBaz() << endl; // 1

    return 0;
}

// LLDB:
// (lldb) p/t true
// (bool) $0 = 0b00000001
// (lldb) p/t false
// (bool) $1 = 0b00000000
// (lldb) p/t d.fooBarBaz
// (Demo::(anonymous struct)) $2 = (foo = 0b0, bar = 0b0, baz = 0b0)
// (lldb) p/t d.fooBarBaz
// (Demo::(anonymous struct)) $3 = (foo = 0b1, bar = 0b0, baz = 0b1)
// (lldb) p 0b00000001 == 0b1
// (bool) $4 = true
// (lldb) p 0b00000000 == 0b0
// (bool) $5 = true

在 C++ 中 truefalse 是整型十进制中的 10,整型在内存中占用一个字节,即 8 位,换算为二进制为 0b 0000 0001(即 0b1)和 0b 0000 0000(即 0b0),而 foobarbaz 各占一位,因此当赋值 truefalse 时,将其各自的位置为 10,也就达到了使用一位来存储布尔类型的目的。

共用体

在 C/C++ 等其它语言中都有结构体这种数据结构,我们经常会定义结构体类型来存储多个相关变量,在内存中变量依次占据连续的内存空间。而 C/C++ 中的共用体中也可以存储多个相关变量,不同的是变量在内存中占用同一起点的内存空间

我们定义一个 FooBar 共同体,其中有两个类型不同的变量,int 类型的 foochar 类型的 bar。整个共用体结构的大小为其中最大元素的大小,即 int 的 4 个字节。由于共用用一块内存空间,当我们为 foo 赋值 65 时,bar 所占用的一个字节也被赋值了,因此最终输出了 A

union FooBar
{
    int foo;
    char bar;
};

int main()
{
    cout << sizeof(FooBar) << endl; // 4

    FooBar fbb;
    fbb.foo = 65;

    cout << fbb.foo << endl; // 65
    cout << fbb.bar << endl; // A

    return 0;
}

位域与共用体

上面我们简单认识了位域与共用体各自的作用,但位域只能按位赋值,共用体中各自变量内存重叠用处也似乎有局限。那为何不将多个位共用一个变量大小的内存空间呢?

union FooBar
{
    char bits;

    struct
    {
        char foo : 1;
        char bar : 1;
        char baz : 1;
    } fooBarBaz;
};

这里我们定义了一个 FooBar 共用体,虽然其中有两个变量,但根据共用体的定义,bits 将和位域 fooBarBaz 共享同一个字节的内存空间;而根据位域的描述,这一个字节的 8 位中,foobarbaz 各自占用一位。这样的好处是,比单一使用共用体更加直观,外界操作时却只需要访问 bits 即可。

那么如何只访问 bits 就可以操作三个不同的变量呢?我们要首先了解一下 &| 即按位与、按位或运算符。& 按位与,即按位计算,只有同时为 1 时该位结果才为 1| 按位或,即按位计算,只有同时为 0 时该位结果才为 0;另外还要知道左移 <<、右移 >>、取反码 ~ 运算符,左移即将二进制位整体向左移动,右移反之,取反码则根据 01 对立互取即可。

通过位域与共用体,以及上述运算符我们就可以实现了:

#define FooMask (1 << 0) // 0b 0000 0001
#define BarMask (1 << 1) // 0b 0000 0010
#define BazMask (1 << 2) // 0b 0000 0100

class Demo
{
    FooBar fooBar;

public:
    void setFoo(bool foo)
    {
        if (foo)
        {
            fooBar.bits |= FooMask;
        }
        else
        {
            fooBar.bits &= ~FooMask;
        }
    }

    bool getFoo()
    {
        return fooBar.bits & FooMask;
    }

    void setBar(bool bar)
    {
        if (bar)
        {
            fooBar.bits |= BarMask;
        }
        else
        {
            fooBar.bits &= ~BarMask;
        }
    }

    bool getBar()
    {
        return fooBar.bits & BarMask;
    }

    void setBaz(bool baz)
    {
        if (baz)
        {
            fooBar.bits |= BazMask;
        }
        else
        {
            fooBar.bits &= ~BazMask;
        }
    }

    bool getBaz()
    {
        return fooBar.bits & BazMask;
    }
};


int main(int argc, const char * argv[]) {
    cout << sizeof(FooBar) << endl; // 1

    Demo d = Demo();
    d.setFoo(true);
    d.setBar(false);
    d.setBaz(true);

    cout << d.getFoo() << endl; // 1
    cout << d.getBar() << endl; // 0
    cout << d.getBaz() << endl; // 1

    return 0;
}

当然,第一次使用可能比较抽象,我们以一对 getter & setter 为例说明下:

void setFoo(bool foo)
{
    if (foo) // 当 foo 为真时,我们需要将 foo 的二进制位置为 1
    {
        // FooMask 是 foo 的掩码,即 1 << 0,0b 0000 0001

        // 此时我们需要仅将 bits 的最后一位置为 1,而不能更改其它位
        // 因此这里选择需要与掩码逻辑或,保证其它位和 0 运算都能得到其原先值并将 foo 置为 1
        fooBar.bits |= FooMask;
    }
    else // 当 foo 为假时,我们需要将 foo 的二进制位置为 0
    {
        // 此时我们需要仅将 bits 的最后一位置为 0,而不能更改其它位
        // 因此首先需要取掩码的反码,并进行逻辑与,保证其它位不变并将 foo 置为 0
        fooBar.bits &= ~FooMask;
    }
}

bool getFoo()
{
    // 获取值时,我们仅需要特定位即可,因此与掩码逻辑与可将其它位置为 0,并将需要的位取出
    return fooBar.bits & FooMask;
}

Reference