Date Notes Env
2019-07-23 首次提交 clang++、macOS 10.14.4

0

Preface

conststatic & extern 是 C/C++ 中的关键字,而在 iOS 开发中的 Obj-C/C++ 又是 C/C++ 的超集,那么本篇就简单梳理一下这些关键字的作用。

const

基础类型

int a = 0;
a = 10;

对于基础类型,其在内存中存储的即是该类型的变量本身,经过 const 修饰后,此处的变量不可变,即常量。const 的位置并不会影响其作用,更为推荐的写法应当是编译器提示的 const <TYPE>

以下是在 C/C++ 中的用例,由于 Obj-C/C++ 分别是 C/C++ 的超集,对于 C/C++ 的基础类型,其在 Obj-C/C++ 中的表现是一致的:

const int b = 0;
// error: cannot assign to variable 'b' with const-qualified type 'const int'
// b = 10;

int const c = 0;
// c = 10;

对于 Obj-C 中特有的基础类型,const 也与修饰 C/C++ 中基础类型的行为一致:

BOOL a = YES;
a = NO;

const BOOL b = YES;
// Cannot assign to variable 'b' with const-qualified type 'const BOOL' (aka 'const signed char')
// b = NO;

BOOL const c = YES;
// c = NO;

数组类型

int a[] = {1, 2, 3};
a[0] = 0;

对于数组类型,即使用连续的一段内存来容纳同一类型元素的容器。当在 C/C++ 中使用 const 修饰基础类型的数组时,其中的内容将不能再改变:

int const b[] = {1, 2, 3};
// error: read-only variable is not assignable
// b[0] = 1;

const int c[] = {1, 2, 3};
// c[0] = 1;

对于 Obj-C,在 C 语言之上添加了面向对象的部分,其需要存储自身定义的对象类型的容器类,所以数组等集合(注:这里的集合并非特指 Set,而是指 Collection,下略)类型被单独拎出来独立为 NSArrayNSSet 等类型,且默认即作为不可变版本,并在使用时需结合指针。需要注意的是,这里的不可变仅针对内部元素(即变量内部地址指向的值),对于指针本身(数组的首地址)是可变的。可变版本 NSMutableArray 等则可以修改内部元素和指针本身。

由于 Obj-C 自带可变和不可变版本的集合类型,const 的作用其实就显得有些多余,这里的 const 将类似针对指针类型的行为(关于指针详见下文)。当 const 位于 NSArrayNSMutableArray 之前或之后时均没有作用:

NSArray *a = @[@1, @2, @3];
// Expected method to write array element not found on object of type 'NSArray *'
// a[0] = @0;
a = @[@3, @2, @1];

const NSArray *b = @[@1, @2, @3];
// b[0] = @0;
b = @[@3, @2, @1];

NSArray const *c = @[@1, @2, @3];
// c[0] = @0;
c = @[@3, @2, @1];

NSArray * const d = @[@1, @2, @3];
// d[0] = @0;
// Cannot assign to variable 'd' with const-qualified type 'NSArray *const __strong'
// d = @[@3, @2, @1];

const 位于 NSArray *NSMutableArray * 之后时,将使得指针内部存储的内存地址不可被改变,它们也就无法被指向新的 NSArrayNSMutableArray 对象了。

NSMutableArray *e = [a mutableCopy];
e[0] = @0;
e = [b mutableCopy];

const NSMutableArray *f = [a mutableCopy];
f[0] = @0;
f = [b mutableCopy];

NSMutableArray const *g = [a mutableCopy];
g[0] = @0;
g = [b mutableCopy];

NSMutableArray * const h = [a mutableCopy];
h[0] = @0;
// Cannot assign to variable 'h' with const-qualified type 'NSMutableArray *const __strong'
// h = [b mutableCopy];

指向基础类型的指针

在上一节中,我们已经接触了 Obj-C 中的 NSArray 的指针类型 NSArray *,但为了更加清晰,我们将重新先从指向基础类型的指针开始。

物理上的内存会被操作系统映射为一连串的内存地址,我们假设一段内存从 0x0000 开始,正如下图中一连串的小方格,每个小方格代表一个字节大小的内存。当我们声明一个基础类型的变量时,将为其分配一段内存空间来存储其中的值。比如 int a = 10;,会分配 4 个字节长度的内存,10 将以二进制的形式存储在这段内存中。需要注意的是 iOS 默认的小端(Little End)模式会在低地址存储高位字节,例如下图中的橙色部分,即 10100000

指针,是指存储内存地址的一种数据类型,内存地址是由编译器来决定的,因此指针的大小由编译器决定。在 64 位 clang++ 编译器中,内存地址的长度为 8 个字节,因此无论 int *double * 还是 char * 类型的指针其大小均为 8 个字节。比如 int *b = &a;& 在 C/C++ 中是取地址的符号,即我们将 a 变量的内存地址存储在 b 中,如下图绿色的部分。因此 b 的值就是 0x00000000,而 *b 取内容才为 10。这时我们就能发现,对于指针类型,其一共有两个部分,一个是变量本身直接存储的内存地址,另一个则是位于该内存地址处的值。

1

int foo = 0;
int bar = 10;

int *c = &foo;
c = &bar;
*c = foo;

那么,当指针遇上 const,哪一部分将不可变呢?当 const 修饰指针中内存地址对应变量的类型时,即在表达式最左边或在 * 左边时,其内部存储的内存地址所对应的值为常量,不可被改变,但我们仍能够通过直接改变指针存储的变量地址而间接改变其值:

const int *d = &foo;
d = &bar;
// error: read-only variable is not assignable
// *d = foo;

int const *e = &foo;
e = &bar;
// *e = foo;

const 位于表达式中 * 右边时,其内部存储的内存地址将无法被改变,但该内存地址所对应的值却可以被改变:

int *const f = &foo;
// error: cannot assign to variable 'f' with const-qualified type 'int *const'
// f = &bar;
*f = foo;

当我们既不希望改变指针中存储的内存地址,也不希望改变该内存地址所对应的值时,就需要同时限定两处,使用两个 const 来达到这种效果:

const int *const g = &foo;
// error: cannot assign to variable 'g' with const-qualified type 'const int *const'
// g = &bar;
// error: read-only variable is not assignable
// *g = foo;

由于 Obj-C/C++ 中的基础类型本质仍然是 C/C++ 中的基础类型,「指向基础类型的指针」将不针对 Obj-C/C++ 举例。

指向对象类型的指针

如果我们已经很好地理解了上一节「指向基础类型的指针」,那么对于指向对象类型的指针也将水到渠成。指向对象类型的指针的本质仍然是指针,因此其内部仍存储的是内存地址,大小也与 int * 一样都是 8 个字节。与指向基础类型的指针不同的是,其内部存储的内存地址对应值是对象类型。

class Foo
{
public:
    double bar;
    int *baz;

    Foo(double _bar, int *_baz)
    {
        bar = _bar;
        baz = _baz;
    }
};

当我们在 C++ 中创建对象时(C++ 中引入了 class 类与对象的概念),new 将申请内存空间,SomeClass() 构造方法将初始化,最终返回对象的首地址,保存在表达式左边的变量中,这也就是为什么这里我们不再需要 & 来取地址。

int a = 1;
int b = 5;

Foo *foo1 = new Foo(0.0, &a);
foo1->bar = 0.5;
cout << foo1->bar << endl;    // 0.5
cout << *foo1->baz << endl;  // 1

a = 10;
cout << *foo1->baz << endl;  // 10

foo1->baz = &b;
cout << *foo1->baz << endl;  // 5

* foo1->baz = 100;
cout << *foo1->baz << endl;  // 100

foo1 = new Foo(1.5, &a);
cout << foo1->bar << endl;    // 1.5
cout << *foo1->baz << endl;  // 10

而当指向对象类型的指针遇到 const 时,就和指向基础类型的指针很类似了。当 const 在表达式最左边或在 * 左边时,对象中的基础类型的值将不可改变,指针类型存储的地址值也将不可改变(类似于 const int *baz)。但此时我们仍可以通过 new 创建新的对象(首地址)或者将其它已创建好的对象(首地址)赋值给变量:

const Foo *foo2 = new Foo(0.0, &a);
// error: cannot assign to variable 'foo2' with const-qualified type 'const Foo *'
// foo2->bar = 0.5;
cout << *foo2->baz << endl;  // 1
a = 10;
cout << *foo2->baz << endl;  // 10
// error: cannot assign to variable 'foo2' with const-qualified type 'const Foo *'
// foo2->baz = &b;
* foo2->baz = 100;
cout << *foo2->baz << endl;  // 100

foo2 = new Foo(1.5, &a);
cout << foo2->bar << endl;    // 1.5
cout << *foo2->baz << endl;  // 100

foo2 = foo1;

const 位于表达式中 * 右边时,其内部存储的内存地址将无法被改变,但该内存地址所对应的值却可以被改变:

Foo * const foo3 = new Foo(0.0, &a);
foo3->bar = 0.5;
cout << foo3->bar << endl;    // 0.5
cout << *foo3->baz << endl;  // 1
a = 10;
cout << *foo3->baz << endl;  // 10
foo3->baz = &b;
cout << *foo3->baz << endl;  // 5
* foo3->baz = 100;
cout << *foo3->baz << endl;  // 100

// error: cannot assign to variable 'foo3' with const-qualified type 'Foo *const'
// foo3 = new Foo(1.5, &a);
// foo3 = foo1;

指向指针的指针

int foo = 0;
int foo2 = 10;
int *bar = &foo;
int *bar2 = &foo2;
int **baz = &bar;

cout << **baz << endl; // 0
baz = &bar2;
cout << **baz << endl; // 10
*baz = &foo2;
cout << **baz << endl; // 10
**baz = foo2;
cout << **baz << endl; // 10

在了解了简单的指针之后,我们再进一步,了解一下「指向指针的指针(也称二维指针)」。指向指针的指针也是指针,因此其中保存的也是内存地址,需要注意的是该内存地址指向的内存空间存储的值仍然是一个内存地址,最终这个内存地址指向的是一个值。

const 位于表达式中的 int 之前或之后时,表示最终指向的 int 值不应被改变,需要注意的是 int ** 将无法赋值给 const int **,因为后者的类型限定更加严格:

const int **baz2;
// error: assigning to 'const int **' from incompatible type 'int **'
// baz2 = &bar;

const int foo3 = 100;
const int *bar3 = &foo3;
const int **baz3 = &bar3;

const 位于表达式第一个 * 之后时,表示第一维指针 *baz4 的值(即 foo 的地址)将无法改变,但我们仍然可以改变第二维指针 baz4 的值(即 bar 的地址)或者 foo 中的值:

int *const *baz4 = &bar;

cout << **baz4 << endl; // 0
baz4 = &bar2;
cout << **baz4 << endl; // 10
// error: read-only variable is not assignable
// *baz4 = &foo2;
**baz4 = foo2;
cout << **baz4 << endl; // 10

const 位于表达式第二个 * 之后时,表示第二维指针 baz5 的值(即 bar 的地址)将无法改变,但我们仍然可以改变第一维指针 *baz5 的值(即 foo 的地址)或者 foo 中的值:

int **const baz5 = &bar;

cout << **baz5 << endl; // 0
// error: cannot assign to variable 'baz5' with const-qualified type 'int **const'
// baz5 = &bar2;
*baz5 = &foo2;
cout << **baz5 << endl; // 0
**baz5 = foo2;
cout << **baz5 << endl; // 0

有了上面几个不同位置的 const 我们就可以组合多个 const 来满足我们的需求。

static

const 约束变量是否可变不同,static 主要是对变量生命周期和作用域的控制,将存储在内存的静态区。

当我们定义一个全局变量,其默认将存储在全局区,作为一个全局符号暴露给外界;而此时如果使用 static 修饰,那么其将只能在当前文件内使用,且其生命周期将持续到程序结束:

int foo = 1;
static int bar = 1;

void baz()
{
    foo = 2;
    bar = 2;
}

int main()
{
    baz();
    printf("%d\n", foo); // 2
    printf("%d", bar);   // 2

    return 0;
}

如何可以证明呢?我们可以使用 nm -gC <OBJECT_FILE> 查看目标文件中的全局符号,其中只有 foo 而没有 bar

➜  ~ nm -gC a.out

0000000100000f20 T baz()
0000000100000000 T __mh_execute_header
0000000100001018 D _foo
0000000100000f40 T _main
                 U _printf
                 U dyld_stub_binder

在 Obj-C 中,我们通常将无需暴露给外界的全局变量使用 static 修饰并放置在相应的实现(.m)文件中,阻止了外界访问也避免了全局符号的冲突。

extern

在软件工程中,我们几乎很少会只使用到一个源文件,而编译通常是针对每个文件进行单独编译为目标文件,并在链接阶段进行链接,最终成为可执行文件。因此在一个文件中需要使用到其他文件中变量时,我们需要使用 extern 来告知编译器在全局符号表中存在该符号,允许编译通过。

// A.m
NSString *const Foo = @"Foo";

// main.m
extern NSString const * Foo;

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSLog(@"%@", Foo); // Foo
    }
}

而如果我们使用了实际不存在但 extern 的变量,将会在编译时出现错误:

Undefined symbols for architecture x86_64:
  "_Foo", referenced from:
      _main in main.o
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

在 Obj-C 中,我们通常将常量声明在实现(.m)文件中,在对应的头(.h)文件中 extern,其他文件需要访问这些常量时,即可通过引入该头文件即可。