属性与变量

runtime简称运行时,就是在程序运行时的一些机制,在iOS开发中runtime的特性使得oc这门语言具有独特的魅力。 对于C、C++来说,在程序编译运行时,类对象能调用哪些方法,能进行什么操作,都被决定好了。而runtime机制让oc能在运行时动态的创建类、黑盒测试、扩展属性等等,极大的提高了语言的灵活性。今天结合runtime的一些机制来谈谈oc的属性和变量。(这是我关于runtime机制的开篇,若文中提及的某些知识点有什么不同的意见,欢迎在评论中与我一同探讨)

property和ivar

首先要确定的是,属性(property)和成员变量(ivar)它们是不同的东西,在oc中它们的区别如下:

  • 成员变量 成员变量通常在类声明@interface或者类实现@implementation后面的大括号中声明的变量,默认修饰为@protected保护类型(文件外不能访问),除此之外还有@public公共类型、@private私有类型和@package包内访问类型

    @interface Person
    {
    @public       ///< 所有可视范围都能访问
        NSString * name;
        NSString * sex;
    @private      ///<  只有本类能够访问
        NSString * personalWealth;
    @protected    ///< 本类和子类都能访问 
        NSString * housesNumber;
    @package      ///<  框架内视为public,框架外为private
        NSString * familyWealth;
    }
    
  • 属性 相比起变量,在编译期间,编译器做了很多工作,包括这些:

    1. 使用@synthesize生成属性对应的ivar,通常ivar命名为下划线+属性名
    2. 生成setter方法来设置ivar
    3. 生成getter方法来获取ivar

从某个意义上来说,属性是对成员变量的封装,在其基础上添加了setter和getter两种方法使变量更符合面向对象的需求。(对于不明白为什么要存在setter和getter的开发者们可以看这篇文章getter和setter方法有什么用

属性的内存结构与@synthesize

在我之前那篇KVO实现文章中,我稍微提到过类的内存结构,这里要更为深入的了解声明属性然后运行后内存结果发生的改变,这里我们会发现@synthesize具体做的事情。 现在我的Person类的代码如下:

@interface Person: NSObject {
    NSString * _name;
    NSString * _sex;
    char _ch;
}

@property(nonatomic, copy) NSString * abc;
@property(nonatomic, copy) NSString * efg;

@end    


@implementation Person

- (instancetype)init {
    if (self = [super init]) {
        NSLog(@"%p, %p, %p, %p, %p, %p, %p", self, &_name, &_sex, &_ch, _abc, _efg);
    }
    return self;
}
 
@end
  • 问题一:成员变量的地址偏移

    虽然OC作为一门动态语言有自己的特性,但是从类结构的角度来说,和其他语言的差别并不会很大。按照类结构的角度来看,类中的成员变量的地址都是基于类对象自身地址进行偏移的,那么这几个变量的地址应该是依次增加0x8(32位系统上则是0x4)。上面代码的日志输出如下:

    0x7fb649c0c9b0, 0x7fb649c0c9c0, 0x7fb649c0c9c8, 0x7fb649c0c9d0, 0x7fb649c0c9d8, 0x7fb649c0c9e0

可以看到后面三个地址确实相差为0x8,但是在类对象和第一个成员变量之间相差的地址是0x10。这是为什么呢? 在苹果开源文件的相关代码中,我们可以找到Class类型的定义

	typedef struct objc_class *Class;
	struct objc_class {
	Class isa;
	······
	}

Class表示OC中的类结构,从这段代码中我们可以看到它是结构体objc_class的指针类型,在这个结构体中有一个isa指针变量。而这个多出的指针变量也不难解释了为什么上面的输出中出现0x10的偏移——两个地址之间相差了一个isa。更为详细的内容,将会在之后其他的runtime文章中具体讲述。

  • 问题二:地址偏移的计算方式是什么?

    指针在64位系统占用8bit这个没有任何问题,但是char类型只用到一bit,但是这里同样偏移了8位,是否也是按照结构体的地址偏移计算的? 这里要提到一个给类添加变量的函数class_addIvar(const char *, NSUInteger *, NSUInteger *),其中最后一个参数用来表示变量的内存地址对其方式。苹果对这个参数解释是:

    The instance variable’s minimum alignment in bytes is 1«align. The minimum alignment of an instance variable depends on the ivar’s type and the machine architecture. For variables of any pointer type, pass log2(sizeof(pointer_type)).

    这里说了alignment是变量以字节为单位的最小对齐方式,但是却 没有细说怎样对齐。而在objc-runtime-new.mm中有地址偏移计算的代码,我们可以通过这些代码了解的更清楚:

        uint32_t offset = cls->unalignedInstanceSize();
        uint32_t alignMask = (1<<alignment)-1;
        offset = (offset + alignMask) & ~alignMask;
    

简单来说就是苹果规定了某个变量它的偏移默认为1 << alignment,而在上下文中这个值为指针长度。因此,OC中类结构地址的偏移计算与结构体还是有不同的,只要是小于8bit长度的地址,统一归为8bit偏移。

  • 问题三:属性的变量是怎么存放的?

    前面我们说过了使用@property声明的属性在编译阶段会自动生成一个以下划线开头的ivar并且绑定setter和getter方法,所以我们可以在类文件中使用_property的方式访问变量。那么根据上面的地址偏移的输出,属性生成的变量实际上是跟在成员变量的后面的,那么这是怎么实现的? 在问题二中我提到了一个runtime的函数class_addIvar(),在Xcode中函数的描述如下:

      * @note This function may only be called after objc_allocateClassPair and before objc_registerClassPair. 
      *       Adding an instance variable to an existing class is not supported.
      * @note The class must not be a metaclass. Adding an instance variable to a metaclass is not supported.
      * @note The instance variable's minimum alignment in bytes is 1<<align. The minimum alignment of an instance 
      *       variable depends on the ivar's type and the machine architecture. 
      *       For variables of any pointer type, pass log2(sizeof(pointer_type)).
    

    在编译器编译代码的期间,对类的操作包括了创建类内存、添加变量、属性、方法列表……操作,在完成这些操作之后,还需要注册类类型后才能够使用。而class_addIvar()函数在注册前使用,为类添加成员变量并且加入变量列表当中。根据这个函数,我们推测@synthesize在编译期间通过了这个函数为属性添加实例变量,并且存放起来。如果我们的猜测是正确的,那么我们可以在实例变量的列表中找到这些属性对应的变量。 对于这个问题,runtime同样提供了方法给我们进行测试。Ivar * class_copyIvarList(Class, unsigned int *)返回类结构中的变量列表,我们可以通过下面的代码获取Person所有的变量并且输出变量名:

      unsigned int ivarCount;
      Ivar * ivars = class_copyIvarList([Person class], &ivarCount);
    	    
      for (int idx = 0; idx < ivarCount; idx++) {
      Ivar ivar = ivars[idx];
      NSLog(@"%s", ivar_getName(ivar));
      }
      free(ivars);
    

上面Person类的实例变量列表输出结果如下:

	2016-01-07 21:59:49.580 LXDCodingDemo[3036:255608] _omg
	2016-01-07 21:59:49.581 LXDCodingDemo[3036:255608] _name
	2016-01-07 21:59:49.581 LXDCodingDemo[3036:255608] _ch
	2016-01-07 21:59:49.581 LXDCodingDemo[3036:255608] sct
	2016-01-07 21:59:49.581 LXDCodingDemo[3036:255608] _sex
	2016-01-07 21:59:49.581 LXDCodingDemo[3036:255608] _copying
	2016-01-07 21:59:49.581 LXDCodingDemo[3036:255608] _egf
	2016-01-07 21:59:49.581 LXDCodingDemo[3036:255608] _hij

我们可以看到@synthesize确实调用了这个方法,其绑定属性与变量内存的方式是通过class_addIvar()函数来实现的。

  • 问题四:@synthesize到底做了什么?

    这个问题可能有些匪夷所思,从上面的代码跟问题结合来看,毫无疑问@synthesize为变量生成并且绑定了变量内存。 我们在声明属性的时候,比如Person类中的abc属性,那么编译器会在编译期间帮我们自动生成@synthesize abc = _abc;这句代码,这意味着我们可以自己来写出这句。那么假如我们把属性和已存在的成员变量进行绑定呢?比如写成@synthesize abc = _name,那么修改之后再次输出地址会变成怎样?

      @implementation Person
      @synthesize abc = _name;      ///< 自定义绑定属性
    	
      - (instancetype)init {
      if (self = [super init]) {
          NSLog(@"%p, %p, %p, %p", &_name, &_sex, &_ch, &_efg);
      }
      return self;
      }
    	
      @end
    

原先的代码在添加了自定义绑定的这句代码后会报错,由于我们给abc属性绑定了_name的内存地址,那么编译器就不会生成_abc变量,所以在类中找不到这个变量的存在。在创建Person的实例后控制台输出的地址信息没有发生变化,依旧是相差0x8

	0x7ff92b45a438, 0x7ff92b45a440, 0x7ff92b45a448, 0x7ff92b45a458

为了检测abc_name的关系,我在main函数中加入了这段代码:

	Person * p = [Person new];
	p.abc = @"123";
	NSLog(@"%@, %@", p.abc, p->_name);

输出的结果是abc_name的结果是一样的。通过这个小🌰,我们不难发现@synthesize在为属性添加变量内存的时候,会先搜索是否已经存在同名的实例变量,如果存在,将生成getter和setter方法来访问这块内存地址。否则生成新的成员变量地址,然后再绑定setter和getter。因此@synthesize在添加变量的工作中不仅仅是简单的class_addIvar(),还有遍历变量列表的过程。

跟黑白对立一样,有了@synthesize这样的存在,必然也会有相反的机制,在OC中我们可以使用@dynamic propertyName的方式阻止编译器为属性完成变量捆绑和setter、getter生成的工作,然后交由我们在运行时再去生成这些方法。这些将会在runtime的消息篇中讲解。

  • 问题五:@synthesize如何判断属性的类型?

    假如我们在上面自定义的绑定代码中绑定的不是_name而是_ch呢?那么编译器会报错,这是由于类型检测的结果。但是编译器在默认生成属性对应的变量内存的时候,又是怎么判断属性的类型的?另外,属性还拥有着copystrongweak···更多的属性类型,这关乎setter方法的实现,@synthesize又是怎么区分的?在Xcode中有个并不常用的关键字@encode,这个关键字使用后返回描述类型的编码,我在main函数中添加了这么一段代码以及控制台的输出结果:

      NSLog(@"%s, %s, %s", @encode(Person), @encode(CGRect), @encode(NSInteger));
    	 
      ///输出
      {Person=@@c}, {CGRect={CGPoint=dd}{CGSize=dd}}, q
    

看起来有些混乱,在苹果官方文档中提到了编译器用C字符来表示所有的OC类型,而使用@encode(type)可以获取这个类型的编码,这些编码的对应关系在类型编码中可以看到。

从上面的输出中我们看到了Person对应的编码是@@c,其中表示对象,后面跟着的分别表示ididchar,结合类文件来看,这里分别表示_name_sex_ch。那么这也就可以看出@synthesize是怎么判断出属性绑定的变量类型了。而在class_addIvar()函数中接受一个const char *类型的参数用来表示实例变量的属性类型、变量类型等,这时候@synthesize就能将获取的类型编码传入然后生成对应的变量。

另外,对于属性类型的判断又是怎么样的呢?同样的,苹果在runtime中提供给我们property_getAttributes()来获取一个对象的类型属性,这些类型属性也同样采用了@encode类似的一套类型编码,这些类型编码的标准表同样可以在属性类型编码中找到。 如果你喜欢看各种开源框架的代码,那么最近突起的YYModel中你可以看到作者对于类型编码的大量应用:

应用

不能实践的理论都是废话 —— 沃德天·毫率

上面我总结出了很多头头是道的理论,但是如果不能使用并没有什么卵用。在我们开发中,数据持久化是避不可免的业务实现,由于博主公司项目都不大,也没有太多的数据需要存储,因此正常来说博主都是直接使用NSCoding提供的数据归档进行的持久化。那么就经常出现这样的代码: 首先在模型数据还没有那么多的时候,这么写并不会出现什么问题。当模型的数据越来越多,直接这么写就可能导致:

  1. 数据过多导致归档操作中字符串可能对应不上,导致存取失败
  2. 工作量加大

上面我们说到过runtime中存在class_copyIvarList()函数来获取一个类的所有实例变量,对于属性同样存在着class_copyPropertyList()函数。因此,我们可以通过这个函数来遍历获取属性以及属性名称,然后实现类似单例宏定义的一键归档宏定义。核心代码如下:

  unsigned int propertyCount; 
  objc_property_t * properties = class_copyPropertyList([self class], &propertyCount);    

  for (int idx = 0; idx < propertyCount; idx++) {
      objc_property_t property = properties[idx];
      NSLog(@"\n--name: %s\n--attributes: %s", property_getName(property), property_getAttributes(property));  }   
  }   
  free(properties);   

控制台输出属性的相关信息:

--name: abc
--attributes: T@"NSString",C,N,V_abc

--name: efg
--attributes: T@"NSString",C,N,V_efg

--name: hij
--attributes: T@"NSString",C,N,V_hij

通过runtime来遍历类属性然后进行归档和反归档的过程中都有这么一段遍历属性的过程,那么可以定义一个LXDCodingHandler的block用来存储遍历中对objc_property_t相关属性的处理并传入这个遍历中:

typedef void(^LXDCodingHandler)(objc_property_t property, NSString * propertyName);

相关代码我已经完成了封装,实现了一行代码对模型进行序列化操作。demo地址

转载请注明本文地址和作者