在 OC 中,有很多常用的关键字。如何正确使用这些关键字,是学习一本语言的基础。通常面试官只需要问几
个关键字相关的问题,就能看出面试者的基础如何。例如 #include、#import、@class的区别,什么时候用 const NSString * 什么时候用 NSString * constdefinestatic 的正确使用等。不仅要知道怎么用,还要知道为什么这样用,不能只是“我看别人这么写”。

这篇文章将介绍一些关键字的使用及原理。

static & const & extern

将一些重复使用的字符串定义为字符串常量是一种良好的习惯,这样写起来代码便于维护。当然有时也会定义成,后面会解释两者区别。在定义常量时,通常会用 staticextern 来定义常量的作用域,用 const 来定义常量的可变性

1.static

static 关键字,主要定义变量的作用域生命周期

static 修饰局部变量,主要定义变量生命周期,静态局部变量,因为存储在全局数据区,不会像其他存储在栈区的局部变量一样随着函数体结束被释放。

static 修饰全局变量,定义变量的作用域,被 static 修饰的量,只存储一份,始化一次,其他地方共享这一份数据。在 OC 中,static 变量声明一般在源文件( “.m” )中,如果放在头文件( “.h” )中,其他文件引入这个头文件时,容易引起命名冲突。被 static 修饰的全局变量,作用域为当前文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 1.申明在源文件中,声明在头文件中容易引起命名冲突
// 在类 TestClass 中
static NSString *str = @"hello world";

@interface TestClass : NSObject

@end

// 在类 OtherClass 中
#import "TestClass.h"

static NSString *str = @"welcome!"; // 编译不通过,这里会报 “Redefinition of 'str'” 重复定义的错误
@implementation OtherClass

@end

=======================================================
// 2.只存储一份,初始化一次,其他使用地方共享

static int numA = 0;

@implementation TestViewcontroller

- (void)viewDidLoad {
for (int i=0; i < 5; i++) {
[self addNum];
}

// numA = numC = 5; numB 一直为1,方法调用结束后被释放。
}

- (void)addNum {
int numB = 0;
static int numC = 0;
numA++;
numB++;
numC++;
}
@end

tips: 如果一个变量在当前文件中被多处使用,建议使用 static 定义为当前类的全局变量

2.extern

extern 关键字,主要用来定义外部全局变量。前面说用 static 定义作用域为当前文件的全局变量。那如果想定义作用域为整个工程文件全局变量,即外部全局变量,则用 extern

一般在头文件中使用 extern 声明变量,在源文件中赋值,尽量不要将外部全局变量的值暴露在头文件中;或者在头文件中声明,在其他文件中使用的时候再进行赋值。extern 关键字只对变量进行声明,表明该变量可能在本模块使用也可以在其他模块使用。例如类B如果想使用类A中定义的全局变量,只需要引入类A的头文件即可,这样即使在编译的时候找类B不到变量的定义也不会报错,它会在链接的时候在类A的目标代码中找到这个变量。

1
2
3
4
5
6
7
8
extern NSString * notificationName;

@interface TestClass: NSObject

@end
// TestClass.m 源文件中
#import "TestClass.h"
const NSString * notificationName = @"notificationName";

多说一点
在 Apple API 中,我们可以看到一些与 extern 相关的宏定义,例如 FOUNDATION_EXTERNUIKIT_EXTERN等。我们可以看一下其中一个的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// FOUNDATION_EXTERN 定义
#if defined(__cplusplus)
#define FOUNDATION_EXTERN extern "C"
#else
#define FOUNDATION_EXTERN extern
#endif

// AVKIT_EXTERN 定义
#ifdef __cplusplus
#define AVKIT_EXTERN extern "C" __attribute__((visibility ("default")))
#else
#define AVKIT_EXTERN extern __attribute__((visibility ("default")))
#endif

// UIKIT_EXTERN 定义
#ifdef __cplusplus
#define UIKIT_EXTERN extern "C" __attribute__((visibility ("default")))
#else
#define UIKIT_EXTERN extern __attribute__((visibility ("default")))
#endif

OC 是支持与 C++ 混编的。__cplusplus 是 C++ 中自定义宏,上面这段宏表示如果这是一段 C++ 代码,则使用 extern "C"。那么问题来了,为什么要用 extern "C" 呢?在 C++ 中,是支持重载的。就是函数名可以一样,在编译处理时,会将“函数名及返回类型+参数及返回类型”合成一个字段,以此判断是哪个函数;但是 C 中是不支持重载的,编译时只会将函数名合成一个字段,即 C 和 C++ 对函数名的处理是不一样的。C++ 为了兼容 C,在C++代码中调用 C 编码的文件,就需要用 extern"C" 来告诉编译器:这是一个用 C 编码的文件,请用 C 的方式来链接它们。因此在进行 OC 与 C++ 混编时,用FOUNDATION_EXTERN 去修饰全局方法。

其他的例如 UIKIT_EXTERNAVKIT_EXTERN 等与此类似,只是名字不同,目的是为了在不同的 framework 中使用时命名区分。平常定义一些外部全局变量时,直接使用 extern 即可。

3.const

const 关键字,多与 staticextern 连用,定义的类型为常类型,属性为 readonly。当初学习 C++ 时经常被这几个名词搞懵逼:常指针,指向常量的指针,指向常量的常指针。对应到 OC 上大同小异,请注意”异”在哪里。const 一般有两种用法:
(1)修饰基本变量,即 int、double、float 等类型

1
2
3
4
// 下面两种写法是等价的
const int a = 10; // a 不可变
int const a = 10; // a 不可变
a = 12; // error

(2) 修饰指针变量。在 OC 中,很多数据对象类型都有 mutable(可变)immutable(不可变) 两种。const 在修饰”不可变”的指针变量时,多被用做定义”指针常量”。因为指针已经为不可变,再用 const 修饰没有意义。

1
2
3
4
5
6
7
8
9
10
11
12
13
// const 定义 "常量指针",没有什么意义。'值'不可变的本身就不可变,可变的依然可变。
const NSString *str1 = @"hello"; // const 修饰不可变字符串,字符串本身就不可变。
const NSMutableString *str2 = [NSMutableString stringWithFormat:@"you"]; // const 修饰可变字符串
[str2 appendString:@"name"]; // str2 的值为@"you name"。str2 可以改变,const 没有起到作用。

==================================================================================================

// const 定义指针常量
NSString * const str3 = @"const value";
str3 = @"change point"; // 此种操作不合法,str3 指向对象不能改变
NSMutableString * const str4 = [NSMutableString stringWithFormat:@"mutable value"];
str4 = [NSMutableString stringWithFormat:@"change point"]; // 此种操作不合法,str4 指向对象不能改变
[str4 appendString:@"change value"]; // str4 的值可以改变。所以 str4 的定义方式没有意义。

综上,如果想定义不可变字符串(不可变数据对象),直接用 NSString;如果想定义可变字符串(可变数据对象),直接用 NSMutableString;如果想定义一个不可以改变的字符串(数据对象),即值不可变,指向对象也不可变,用 NSString * const str = @"xxx" 方式。且定义时就应赋值,如果不赋值,后面一直为 nil;如果想定义一个值可以改变,所指对象可以改变的字符串(数据对象),直接用 NSString 不就可以了么?

4.const 与 static、extern 混用

如果需要在文件内部定义一个全局不可变常量,例如 NSDictionary 的”key”,可以这样定义:

1
2
// .m 文件中
static NSString * const kValueKey = @"key"; // 如果变量只在当前文件使用,变量名前面加小写字母 'k',习惯。

如果需要定义一个外部使用的全局不可变常量,例如 NSNotification 的”name”,可以这样定义:

1
2
3
4
5
// .h 文件中
extern NSString * const defineNotification;

// .m 文件中
NSString * const defineNotification = @"defineNotification";

如果只是单纯的定义通知名字,Apple 给提供了关键字 NSNotificationName。本质上没有什么区别,只不过命名习惯让人看起来舒服些。定义方式如下:

1
2
3
4
5
// .h 文件中
extern NSNotificationName const defineNotification;

// .m 文件中
NSNotificationName const defineNotification = @"defineNotification";

#define

宏定义(#define)从上古 C 系编程的时代就存在,一个好的宏定义,能够让代码看起来更简洁、优雅。宏定义主要分为对象宏函数宏。宏定义在预编译阶段进行替换,不做类型检查。因此,宏定义的使用过程中有很多坑,尤其是在函数宏中。如果没有足够的功底,不要轻易写函数宏,否则会有惊喜。有关宏的深入了解,可以看一下喵神的宏定义的黑魔法 - 宏菜鸟起飞手册。希望你看完之后能够更优雅的使用宏,尤其是函数宏。

宏定义可以提升代码的优雅度,但也不能滥用。像上文中说的,一些”key”或者”notificationName”最好定义为静态常量。建议,将系统主题配置的数据定义为对象宏,例如主题色、字体大小、高度等,方便修改和使用;将常用并且冗长的 API 调用定义为函数宏,例如屏幕大小、系统版本判断等,用起来简洁、方便,减少大量冗余代码。还有很多使用场景,可以参考 Apple API,或者在平时敲码中进行积累。

最后,一个烂大街的问题就是:”#define 和 const”的区别。主要由以下几种区别:

  • 编译处理过程的区别
    define宏在预处理阶段进行展开、替换,define宏没有类型,不做类型安全检查。宏定义是在预处理阶段进行替换,大量使用宏定义会造成编译时间过长。;const 常量在编译阶段使用,有具体类型,在编译阶段会进行类型检查。也就是说你用 define 定义一个字符串类型,然后赋值给一个浮点型变量,在编译阶段是不会报错的。但是现在的一些 IDE 都会有提示,例如 Xcode 就会提示对应错误。

编译四个大体步骤:预处理->编译->汇编->链接

  • 内存管理方式的区别
    正如很多文章里说的那样,宏定义不分配内存,变量定义分配内存。宏定义给出的是立即数,每有一次替换,变会分配一次内存,在内存中有若干个拷贝;const 常量给出的是内存地址,存储在全局静态区,只有一次拷贝,一份内存,效率要比宏定义高。

这里有一个误区:这里说的”分配内存”是指在给变量或者常量赋值时,创建临时变量分配的内存。不是变量或者常量占用的内存。例如下面:

1
2
3
4
5
6
#define MAX_COUNT 100			   // 宏常量
const CGFloat height = 20.5f; // 定义时并未分配内存
int count1 = MAX_COUNT; // 编译期间进行替换,编译期间不进行内存分配。运行时为 count1 赋值时,需要创建 MAX_COUNT 临时变量,宏的多次分配内存,是为赋值时 MAX_COUNT 这个临时变量分配的内存。不是指的 count1 ,不要混淆。
CGFolat viewHeight = height; // 此时为 const 常量 height 分配内存,此后不再分配。
int count2 = MAX_COUNT; // 再次为创建 MAX_COUNT 临时变量分配内存。
CGFloat labelHeight = height; // 此时不再为 height 分配内存

  • 修饰区别
    define宏可以定义常量,也可以定义方法;const只能用来定义常量,不能用来修饰方法。
1
2
3
4
#define NSLogRect(rect) NSLog(@"%s x:%.4f, y:%.4f, w:%.4f, h:%.4f", #rect, rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)   // 喵神写的一个输出 rect 的函数宏
#define SCREEN_WITH [[UIScreen mainScreen] bounds].size.width // 屏幕高度

NSString * const key = @"key"; // 只能定义常量,不能定义函数

id & instancetype

id 被称为”万能指针”,可以指向任何对象,可以用于任何类型的对象。由 id 关键字定义的对象,在编译器看来只是一个对象指针,关于对象的信息,需要等到运行时才能确定。也就是说,id 定义的对象不做类型检查,向它发送未知的消息,编译阶段不会报错。id 在 OC 中如下定义:

1
2
3
4
5
6
7
8
9
10
11
12
#if !OBJC_TYPES_DEFINED
/// Class 是一个 objc_class 结构体指针.
typedef struct objc_class *Class;

/// objc_object 结构体,里面是一个 Class 类型成员.
struct objc_object {
Class isa OBJC_ISA_AVAILABILITY;
};

/// id 为一个 objc_object 结构体指针.
typedef struct objc_object *id;
#endif

从上面代码可以看出,id 本质是一个结构体指针,结构体中只有一个成员 isa。任何一个 OC 对象,都会带一个默认的 isa 指针来存储对象的具体类型和信息。

id 关键字主要有以下几个使用场景:

1
2
3
4
5
6
7
8
9
// 1.定义 id 类型对象
id newObj = [NSArray array]; // newObj 在运行时才确定指向对象类型为 NSArray,编译时不确定
[newObj log]; // NSArray 类中并没有 'log' 这个对象方法,但是编译时不报错,运行时报错.

// 2.定义 delegate
@property (nonatomic, weak) id<MyDelegate> delegate; // 不确定什么类型的对象作为代理,定义为 id 类型.并且规定实现 <MyDelegate> 这个协议的对象才能作为代理.因此像 delegate 发送消息时,首先要做 respondsToSelector:<#(SEL)#> 检查

// 3.作为返回类型
- (id)copy;

从 clang3.5 开始,出现类 instancetype 关键字。它可以表示一个方法的相关返回类型,与 id 不同的是,instancetype 返回是相关类的具体类型,编译器可以清楚的明确该类的信息,在调用该类的方法和属性时会进行检查。目前一般类的初始化方法,返回类型都为 instancetype

1
2
3
4
5
6
7
8
9
10
// NSArray 的一些初始化方法
+ (instancetype)array;
+ (instancetype)arrayWithObject:(ObjectType)anObject;
+ (instancetype)arrayWithObjects:(const ObjectType [])objects count:(NSUInteger)cnt;
+ (instancetype)arrayWithObjects:(ObjectType)firstObj, ... NS_REQUIRES_NIL_TERMINATION;
+ (instancetype)arrayWithArray:(NSArray<ObjectType> *)array;

- (instancetype)initWithObjects:(ObjectType)firstObj, ... NS_REQUIRES_NIL_TERMINATION;
- (instancetype)initWithArray:(NSArray<ObjectType> *)array;
- (instancetype)initWithArray:(NSArray<ObjectType> *)array copyItems:(BOOL)flag;

#include & #import & @class & @import

1.#include

在编译预处理阶段,预处理器会将一些引入的头文件替换成其对应的内容。例如,在源文件中引入了如下代码:

1
#import <Foundation/Foundation.h>

预处理器对这行代码的处理是用 Foundation.h 文件中的内容去替换这行代码,如果 Foundation.h 中也引用了其他头文件,例如 #import <Foundation/NSArray.h>,则会按照同样的处理方式对引入的头文件进行逐级替代,依次递归下去。

在 C/C++ 中,我们用 #include 引入头文件,用 #include "" 引入自定义头文件,用 #include <> 引入系统头文件。使用双引号 "",系统会优先从自定义头文件去查找,找不到再去系统头文件中找,如果还找不到,编译报错;使用尖括号 <>,系统会直接从系统头文件找,找不到会报错。如果直接用尖括号引入自定义头文件,则会直接报错。使用合理的方式去引入头文件,能够提高编译效率。

2.#import

#import 可以说是 #include 的一个升级,有关 ""<> 的使用与 #include 相同。除此之外,#import 解决了”重复引用“的问题。例如,A,B,C 三个文件,B 引用了 A,C 引用了 B 和 A,这时 C 相当于引用了两次 A。如果直接用 #include 编译会出问题,如果想使用 #include 应该这样写:

1
2
3
4
5
6
7
8
9
10
11
#ifndef(XXX)
  #define XXX
#endif

例如:

#ifndef _AFNETWORKING_
#define _AFNETWORKING_
#import "AFURLRequestSerialization.h"
#import "AFURLResponseSerialization.h"
#endif

如果直接使用 #import,可以避免这个重复引用的问题。在编译的时候它会进行判断,如果已经引入了就不会再次引入。最好的习惯还是尽量不要在头文件(.h)中引入过多的文件,以免加长编译时间。另外,在引入系统文件或者 Pod 中的文件时,最好将包含头文件的外层文件夹一起引入。如果不引入,虽然编译能够通过,但是 Xcode 会提示一些错误,而且调用里面 API 时不会有代码提示。例如:

1
#import <AVFoundation/AVFoundation.h>   // 前面添加 AVFoundation 文件夹

3.@class

@class 是告诉编译器有这样一个类,但是具体这个类里面有什么不知道。好比只给了你一本书的目录,但是没有给你书的内容。那么什么情况下使用 @class 呢?在编译预处理阶段,会将文件中的 .h 文件替换为对应的内容,如果 .h 文件中还引入了其他的 .h 文件,则进行逐级替换,依次递归。因此,尽量不要在 .h 文件中引入其他的 .h 文件。如果在声明一下方法或者属性时,需要用到某个类,这时可以用 @class,并且需要在 .m 文件中以 #improt 的方式再次引入这个文件。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// .h 文件中
#import <UIKit/UIKit.h>
@class UserModel;

@interface ViewController : UIViewcontroller
- (instancetype)initWithUserModel:(UserModel *)userModel; // 此处用到了 UserModel 定义参数类型
@end

// .m 文件中
#import "UserModel.h"
@implementation
// 在这里需要使用 UserModel 中的具体内容,此时需要以 #import 的方式引入。
@end

上面说过,@class 只是告诉有这么一个类,如果使用类中的内容,则会出错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// TestOne.h
typedef NS_EUMU(NSInteger, ReachabilityStatus) {
ReachabilityStatusUnknown = -1,
ReachabilityStatusNotReachable = 0,
ReachabilityStatusReachableViaWWAN = 1,
ReachabilityStatusReachableViaWiFi = 2,
}

// OtherClass.h
@class TestOne;

@interface OtherClass : NSObject
- (void)JudgeStatusWith:(ReachabilityStatus)status; // 这里使用 ReachabilityStatus 会报错

@end

4.@import

在说和这个关键字之前,先说一下 Moudles#import 相对于 #include 解决了重复引用的问题,但同时也带来另外一个问题:当引用关系很复杂时,编译时引用所占的代码量就会大幅上升。如果想解决这个问题,可以在项目文件中的 Supporting Files 组内的 .pch 文件中将经常引用的一些头文件添加进去,解决编译时间问题。默认情况下,里面会引入 UIKit,这是每个文件中经常引用到的文件。

但是并不能把所有的文件都放到 .pch 文件中,因为放入 .pch 中的头文件,每个文件都能访问,这样有些文件就能访问它本不应该访问的文件。

为了解决这个问题,Moudles 出现了。Modules 相当于将框架进行了封装,然后加入在实际编译之时加入了一个用来存放已编译添加过的 Modules 列表。如果在编译的文件中引用到某个 Modules 的话,将首先在这个列表内查找,找到的话说明已经被加载过则直接使用已有的;如果没有找到,则把引用的头文件编译后加入到这个表中。这样被引用到的 Modules 只会被编译一次,提升速度,从而解决了编译时间和访问混乱的问题。

Apple 在 LLVM5.0 引入了一个新的编译符号 @import,使用 @ 符号将告诉编译器去使用 Modules 的引用形式。

1
2
3
4
#import <Foundation/FoundationErrors.h>

// 下面等价于 #import <UIKit/UIKit.h>,同时还增加了 Moudles 的特性。
@import Foundation.FoundationErrors;

pragma

pragma 是一个预处理指令,在 OC 中主要有两个作用:整理代码防止警告

  • 整理代码
    代码是一种艺术,代码写的优雅整洁是艺术的提现。使用 pragma 能够是代码结构看起来更加整洁。具体语法为 #pragma mark 描述内容,或者 #pragma mark - 描述内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@implementation ViewController

#pragma mark - Lifecycle Method

- (void)viewDidLoad {
[super viewDidLoad];
...
}

- (void)viewWillAppear:(BOOL)animated {
...
}

- (void)dealloc {
...
}

#pragma mark - Private Method

- (void)setupView {
...
}

- (void)layoutSubviews {
...
}

#pragma mark - UIScrollViewDelegate

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
...
}
@end

在 Xcode 导航栏看起来效果如下:
(1)#pragma mark 描述内容
pragma 效果图
(2)#pragma mark - 描述内容 (添加了 ‘-‘),代码块之间会有一条线,更加清晰。
pragma 效果图

  • 防止警告
    比起代码结构乱七八糟,更让人崩溃的是,代码有一堆警告。编译器或者静态分析器会针对一些不合格的代码提示”警告”,目的是为了帮助开发者写出更加优秀的代码。在 Xcode 的 Build Settings 里面有关于 warning 提示的设定,如下图:
    warning 设置
    其中三个设定都为 NO,Inhibit All Warnings 意为忽略所有警告,如果你想写出规范的代码,不要开启这个设定;Pedantic Warnings 开启之后,会更加严格检查代码的标准,如果使用系统不支持的一些扩展,会报 WarningTreat Warning as Error 意为将 warning 作为 error 处理,也就是说,开启之后所有的 Warning 全部变为 Error,只要有警告则编译不通过。如果你要严格要求自己,那就开启吧…

但是有时候代码必须要这样写,又不想看到 Warning,可以用预编译指令来处理。这时候可以使用 #pragma,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@implementation ZBWeakTimerTarget

// 下面方法中,屏蔽了 '-Warc-performSelector-leaks' 警告,如果不屏蔽,会报出警告 'PreformSelector may
// cause a leak because its selector is unknown'
- (void) fire:(NSTimer *) timer {
if (self.target) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[self.target performSelector:self.selector withObject:timer.userInfo];
#pragma clang diagnostic pop
} else {
[self.timer invalidate];
}
}
@end

如上述代码中,如果不用 #pragma 进行处理,会报有内存泄漏的警告。因为在 ARC 环境下调用方法时,Runtime 需要知道如何处理返回值。返回值会有 voidintcharNSString *id等类型,ARC 通常会根据你所在操作的对象的头文件进行处理,或忽略,或 retain 等。这个问题的具体解释可以去查看一下 stackoverflow 上面的前三个高票回答。在此主要阐明用 #pragma 可以消除这个 warning。

总结

以上只是对 OC 中部分常用关键字进行一下总结,在 OC 中还有很多关键字,在此就不进行一一分析了。关键字这个东西,用好了能够提高代码的效率和鲁棒性,乱用的话则会造成意想不到的结果。对于一些常用的关键字,建议了解其作用与原理后再去使用。

参考资料