聊聊 Objective-C 中的一些关键字
在 OC 中,有很多常用的关键字。如何正确使用这些关键字,是学习一本语言的基础。通常面试官只需要问几
个关键字相关的问题,就能看出面试者的基础如何。例如 #include、#import、@class的区别
,什么时候用 const NSString *
什么时候用 NSString * const
,define
和 static
的正确使用等。不仅要知道怎么用,还要知道为什么这样用,不能只是“我看别人这么写”。
这篇文章将介绍一些关键字的使用及原理。
static & const & extern
将一些重复使用的字符串定义为字符串常量是一种良好的习惯,这样写起来代码便于维护。当然有时也会定义成宏,后面会解释两者区别。在定义常量时,通常会用 static
和 extern
来定义常量的作用域,用 const
来定义常量的可变性。
1.static
static
关键字,主要定义变量的作用域和生命周期。
static
修饰局部变量,主要定义变量生命周期,静态局部变量,因为存储在全局数据区,不会像其他存储在栈区的局部变量一样随着函数体结束被释放。
static
修饰全局变量,定义变量的作用域,被 static
修饰的量,只存储一份,始化一次,其他地方共享这一份数据。在 OC 中,static
变量声明一般在源文件( “.m” )中,如果放在头文件( “.h” )中,其他文件引入这个头文件时,容易引起命名冲突。被 static
修饰的全局变量,作用域为当前文件。
1 | // 1.申明在源文件中,声明在头文件中容易引起命名冲突 |
tips: 如果一个变量在当前文件中被多处使用,建议使用 static 定义为当前类的全局变量
2.extern
extern
关键字,主要用来定义外部全局变量。前面说用 static
定义作用域为当前文件的全局变量。那如果想定义作用域为整个工程文件全局变量,即外部全局变量,则用 extern
。
一般在头文件中使用 extern
声明变量,在源文件中赋值,尽量不要将外部全局变量的值暴露在头文件中;或者在头文件中声明,在其他文件中使用的时候再进行赋值。extern
关键字只对变量进行声明,表明该变量可能在本模块使用也可以在其他模块使用。例如类B如果想使用类A中定义的全局变量,只需要引入类A的头文件即可,这样即使在编译的时候找类B不到变量的定义也不会报错,它会在链接的时候在类A的目标代码中找到这个变量。
1 | extern NSString * notificationName; |
多说一点
在 Apple API 中,我们可以看到一些与 extern
相关的宏定义,例如 FOUNDATION_EXTERN
、 UIKIT_EXTERN
等。我们可以看一下其中一个的定义:
1 | // FOUNDATION_EXTERN 定义 |
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_EXTERN
、AVKIT_EXTERN
等与此类似,只是名字不同,目的是为了在不同的 framework 中使用时命名区分。平常定义一些外部全局变量时,直接使用 extern
即可。
3.const
const
关键字,多与 static
和 extern
连用,定义的类型为常类型,属性为 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 | // const 定义 "常量指针",没有什么意义。'值'不可变的本身就不可变,可变的依然可变。 |
综上,如果想定义不可变字符串(不可变数据对象),直接用 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 | // .h 文件中 |
如果只是单纯的定义通知名字,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
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 |
|
id & instancetype
id 被称为”万能指针”,可以指向任何对象,可以用于任何类型的对象。由 id 关键字定义的对象,在编译器看来只是一个对象指针,关于对象的信息,需要等到运行时才能确定。也就是说,id 定义的对象不做类型检查,向它发送未知的消息,编译阶段不会报错。id 在 OC 中如下定义:
1 |
|
从上面代码可以看出,id 本质是一个结构体指针,结构体中只有一个成员 isa
。任何一个 OC 对象,都会带一个默认的 isa
指针来存储对象的具体类型和信息。
id 关键字主要有以下几个使用场景:
1 | // 1.定义 id 类型对象 |
从 clang3.5 开始,出现类 instancetype
关键字。它可以表示一个方法的相关返回类型,与 id
不同的是,instancetype
返回是相关类的具体类型,编译器可以清楚的明确该类的信息,在调用该类的方法和属性时会进行检查。目前一般类的初始化方法,返回类型都为 instancetype
。
1 | // NSArray 的一些初始化方法 |
#include & #import & @class & @import
1.#include
在编译预处理阶段,预处理器会将一些引入的头文件替换成其对应的内容。例如,在源文件中引入了如下代码:1
预处理器对这行代码的处理是用 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 |
|
如果直接使用 #import
,可以避免这个重复引用的问题。在编译的时候它会进行判断,如果已经引入了就不会再次引入。最好的习惯还是尽量不要在头文件(.h)中引入过多的文件,以免加长编译时间。另外,在引入系统文件或者 Pod 中的文件时,最好将包含头文件的外层文件夹一起引入。如果不引入,虽然编译能够通过,但是 Xcode 会提示一些错误,而且调用里面 API 时不会有代码提示。例如:
1 |
3.@class
@class
是告诉编译器有这样一个类,但是具体这个类里面有什么不知道。好比只给了你一本书的目录,但是没有给你书的内容。那么什么情况下使用 @class
呢?在编译预处理阶段,会将文件中的 .h 文件替换为对应的内容,如果 .h 文件中还引入了其他的 .h 文件,则进行逐级替换,依次递归。因此,尽量不要在 .h 文件中引入其他的 .h 文件。如果在声明一下方法或者属性时,需要用到某个类,这时可以用 @class
,并且需要在 .m 文件中以 #improt
的方式再次引入这个文件。代码如下:
1 | // .h 文件中 |
上面说过,@class
只是告诉有这么一个类,如果使用类中的内容,则会出错。
1 | // TestOne.h |
4.@import
在说和这个关键字之前,先说一下 Moudles。#import
相对于 #include
解决了重复引用的问题,但同时也带来另外一个问题:当引用关系很复杂时,编译时引用所占的代码量就会大幅上升。如果想解决这个问题,可以在项目文件中的 Supporting Files 组内的 .pch 文件中将经常引用的一些头文件添加进去,解决编译时间问题。默认情况下,里面会引入 UIKit
,这是每个文件中经常引用到的文件。
但是并不能把所有的文件都放到 .pch 文件中,因为放入 .pch 中的头文件,每个文件都能访问,这样有些文件就能访问它本不应该访问的文件。
为了解决这个问题,Moudles 出现了。Modules 相当于将框架进行了封装,然后加入在实际编译之时加入了一个用来存放已编译添加过的 Modules 列表。如果在编译的文件中引用到某个 Modules 的话,将首先在这个列表内查找,找到的话说明已经被加载过则直接使用已有的;如果没有找到,则把引用的头文件编译后加入到这个表中。这样被引用到的 Modules 只会被编译一次,提升速度,从而解决了编译时间和访问混乱的问题。
Apple 在 LLVM5.0 引入了一个新的编译符号 @import
,使用 @ 符号将告诉编译器去使用 Modules 的引用形式。
1 |
|
pragma
pragma
是一个预处理指令,在 OC 中主要有两个作用:整理代码 和 防止警告。
- 整理代码
代码是一种艺术,代码写的优雅整洁是艺术的提现。使用pragma
能够是代码结构看起来更加整洁。具体语法为#pragma mark 描述内容
,或者#pragma mark - 描述内容
。
1 | @implementation ViewController |
在 Xcode 导航栏看起来效果如下:
(1)#pragma mark 描述内容
(2)#pragma mark - 描述内容 (添加了 ‘-‘),代码块之间会有一条线,更加清晰。
- 防止警告
比起代码结构乱七八糟,更让人崩溃的是,代码有一堆警告。编译器或者静态分析器会针对一些不合格的代码提示”警告”,目的是为了帮助开发者写出更加优秀的代码。在 Xcode 的 Build Settings 里面有关于warning
提示的设定,如下图:
其中三个设定都为 NO,Inhibit All Warnings
意为忽略所有警告,如果你想写出规范的代码,不要开启这个设定;Pedantic Warnings
开启之后,会更加严格检查代码的标准,如果使用系统不支持的一些扩展,会报Warning
;Treat Warning as Error
意为将warning
作为error
处理,也就是说,开启之后所有的Warning
全部变为Error
,只要有警告则编译不通过。如果你要严格要求自己,那就开启吧…
但是有时候代码必须要这样写,又不想看到 Warning
,可以用预编译指令来处理。这时候可以使用 #pragma
,代码如下:
1 | @implementation ZBWeakTimerTarget |
如上述代码中,如果不用 #pragma
进行处理,会报有内存泄漏的警告。因为在 ARC 环境下调用方法时,Runtime 需要知道如何处理返回值。返回值会有 void
、int
、char
、NSString *
、id
等类型,ARC 通常会根据你所在操作的对象的头文件进行处理,或忽略,或 retain 等。这个问题的具体解释可以去查看一下 stackoverflow 上面的前三个高票回答。在此主要阐明用 #pragma
可以消除这个 warning。
总结
以上只是对 OC 中部分常用关键字进行一下总结,在 OC 中还有很多关键字,在此就不进行一一分析了。关键字这个东西,用好了能够提高代码的效率和鲁棒性,乱用的话则会造成意想不到的结果。对于一些常用的关键字,建议了解其作用与原理后再去使用。