上一章Objective-C编程快速入门教程请查看:使用对象
封装数据
除了前一章介绍的消息传递行为外,对象还通过其属性封装数据。
本章描述了Objective-C语法,用于声明对象的属性,并解释了这些属性是如何通过访问方法和实例变量的合成在默认情况下实现的。如果属性由实例变量支持,则必须在任何初始化方法中正确设置该变量。
如果一个对象需要通过一个属性维护到另一个对象的链接,那么考虑这两个对象之间关系的性质是很重要的。虽然Objective-C对象的内存管理主要是通过自动引用计数(ARC)来为你处理的,但是知道如何避免像强引用周期这样会导致内存泄漏的问题是很重要的。本章解释了对象的生命周期,并描述了如何通过关系来管理对象图。
属性封装对象的值
大多数对象需要跟踪信息以执行其任务。有些对象被设计用来建模一个或多个值,例如Cocoa NSNumber类用来保存一个数值,或者一个定制的XYZPerson类用来建模一个有姓和名的人。有些对象的作用域更一般,可能处理用户界面及其显示的信息之间的交互,但即使这些对象也需要跟踪用户界面元素或相关的模型对象。
声明公开数据的公共属性
Objective-C属性提供了一种定义类要封装的信息的方法。正如你在属性控制访问一个对象的值中看到的,属性声明包含在一个类的接口中,就像这样:
@interface XYZPerson : NSObject
@property NSString *firstName;
@property NSString *lastName;
@end
在这个例子中,XYZPerson类声明了一个字符串属性来保存一个人的姓和名。
考虑到面向对象编程的主要原则之一是对象应该将其内部工作隐藏在其公共接口之后,因此使用对象公开的行为来访问对象的属性而不是试图直接访问内部值是很重要的。
使用访问器方法获取或设置属性值
通过访问器方法访问或设置对象的属性:
NSString *firstName = [somePerson firstName];
[somePerson setFirstName:@"Johnny"];
默认情况下,编译器会自动为你合成这些访问器方法,因此你只需在类接口中使用@property声明属性即可。
合成方法遵循特定的命名约定:
- 用于访问值的方法(getter方法)具有与属性相同的名称。
- 名为firstName的属性的getter方法也将被称为firstName。
- 用于设置值的方法(setter方法)以单词“set”开头,然后使用大写的属性名。
- 名为firstName的属性的setter方法将被称为setFirstName:。
如果你不希望通过setter方法改变一个属性,你可以在属性声明中添加一个属性来指定它应该是只读的:
@property (readonly) NSString *fullName;
除了向其他对象显示它们应该如何与属性交互之外,属性还告诉编译器如何综合相关的访问方法。
在这种情况下,编译器会合成一个fullName getter方法,但不会合成一个setFullName:方法。
注意:readonly的反义词是readwrite。不需要显式地指定readwrite属性,因为它是默认属性。
如果希望为访问器方法使用不同的名称,可以通过向属性添加属性来指定自定义名称。对于布尔属性(具有YES或NO值的属性),getter方法通常以单词“is”开头。例如,一个名为finished的属性的getter方法应该被称为isFinished。
同样,可以在属性上添加一个属性:
@property (getter=isFinished) BOOL finished;
如果需要指定多个属性,只需将它们包含为逗号分隔的列表,如下所示:
@property (readonly, getter=isFinished) BOOL finished;
在这种情况下,编译器将只合成isFinished方法,而不合成setFinished:方法。
注意:通常,属性访问器方法应该与键值编码(KVC)兼容,这意味着它们遵循显式的命名约定。
有关更多信息,请参见键值编码编程指南。
点语法是访问器方法调用的简洁替代方法
除了执行显式的访问方法调用外,Objective-C还提供了一种替代的点语法来访问对象的属性。
点语法允许你像这样访问属性:
NSString *firstName = somePerson.firstName;
somePerson.firstName = @"Johnny";
点语法纯粹是对访问器方法调用的一种方便的包装。当你使用点语法时,仍然可以使用上面提到的getter和setter方法来访问或更改属性:
- 获取值使用somePerson.firstName和使用[somePerson firstName]是一样的
- 设置一个值somePerson.firstName = @”Johnny”和使用[somePerson setFirstName:@”Johnny”]是一样的。
这意味着通过点语法的属性访问也由属性特性控制。如果一个属性被标记为readonly,那么如果试图使用点语法设置它,就会出现编译器错误。
大多数属性由实例变量支持
默认情况下,readwrite属性将由实例变量支持,实例变量将由编译器自动合成。
实例变量是存在的变量,在对象的生命周期中保存其值。用于实例变量的内存在首次创建对象时分配(通过alloc),在释放对象时释放。
除非另外指定,否则合成的实例变量与属性具有相同的名称,但带有下划线前缀。例如,对于名为firstName的属性,合成的实例变量将称为_firstName。
虽然使用访问器方法或点语法访问对象自己的属性是最佳实践,但是可以直接从类实现中的任何实例方法访问实例变量。下划线前缀表明你访问的是一个实例变量,而不是局部变量。
- (void)someMethod {
NSString *myString = @"An interesting string";
_someString = myString;
}
在这个例子中,很明显myString是一个局部变量,_someString是一个实例变量。
通常,你应该使用访问方法或点语法的属性访问,即使你是在访问一个对象的属性从其自己的实现,在这种情况下,你应该使用self:
- (void)someMethod {
NSString *myString = @"An interesting string";
self.someString = myString;
// or
[self setSomeString:myString];
}
该规则的例外是在编写初始化、释放位置或自定义访问器方法时,如本节后面所述。
可以自定义合成的实例变量名
如前所述,可写属性的默认行为是使用名为_propertyName的实例变量。
如果你想为实例变量使用一个不同的名字,你需要在你的实现中使用以下语法来指导编译器来合成变量:
@implementation YourClass
@synthesize propertyName = instanceVariableName;
...
@end
例如:
@synthesize firstName = ivar_firstName;
在本例中,该属性仍然被称为firstName,可以通过firstName和setFirstName: accessor方法或dot语法访问,但是它将由一个名为ivar_firstName的实例变量支持。
重要提示:如果使用@synthesize时没有指定实例变量名,就像这样:
@synthesize firstName;
实例变量将使用与属性相同的名称。
在本例中,实例变量也将被称为firstName,没有下划线。
可以定义没有属性的实例变量
在需要跟踪值或其他对象时,最好在对象上使用属性。
如果你需要定义你自己的实例变量而不需要声明一个属性,你可以在类接口或实现顶部的大括号中添加它们,就像这样:
@interface SomeClass : NSObject {
NSString *_myNonPropertyInstanceVariable;
}
...
@end
@implementation SomeClass {
NSString *_anotherCustomInstanceVariable;
}
...
@end
注意:你还可以在类扩展的顶部添加实例变量,如类扩展扩展内部实现中所述。
直接从初始化器方法访问实例变量
Setter方法可能有附加的副作用。它们可能触发KVC通知,或者在你编写自己的自定义方法时执行进一步的任务。
应该始终从初始化方法中直接访问实例变量,因为在设置属性时,对象的其余部分可能还没有完全初始化。即使你不提供自定义访问器方法,或者不知道自己的类中有任何副作用,将来的子类也很可能会覆盖这种行为。
一个典型的init方法是这样的:
- (id)init {
self = [super init];
if (self) {
// initialize instance variables here
}
return self;
}
init方法应该在执行自己的初始化之前将self分配给调用超类的初始化方法的结果。超类可能无法正确初始化对象并返回nil,因此在执行自己的初始化之前,应该始终检查以确保self不是nil。
通过在方法的第一行调用[super init],一个对象从它的根类开始,依次通过每个子类init实现初始化。图3-1显示了初始化XYZShoutingPerson对象的过程。
图3-1初始化过程
正如你在前一章中看到的,可以通过调用init来初始化对象,也可以通过调用使用特定值初始化对象的方法来初始化对象。
在XYZPerson类的情况下,提供一个初始化方法来设置人的姓和名是有意义的:
- (id)initWithFirstName:(NSString *)aFirstName lastName:(NSString *)aLastName;
你可以像这样实现这个方法:
- (id)initWithFirstName:(NSString *)aFirstName lastName:(NSString *)aLastName {
self = [super init];
if (self) {
_firstName = aFirstName;
_lastName = aLastName;
}
return self;
}
指定的初始化器是主要的初始化方法
如果一个对象声明了一个或多个初始化方法,你应该确定哪个方法是指定的初始化器。这通常是为初始化提供最多选项的方法(例如参数最多的方法),为了方便起见,你还可以通过编写其他方法来调用它。你通常还应该覆盖init来调用具有适当默认值的指定初始化器。
如果XYZPerson也有一个生日属性,那么指定的初始化器可能是:
- (id)initWithFirstName:(NSString *)aFirstName lastName:(NSString *)aLastName
dateOfBirth:(NSDate *)aDOB;
该方法将设置相关的实例变量,如上所示。如果你仍然希望提供一个方便的初始化器,只针对姓和名,你可以实现调用指定初始化器的方法,就像这样:
- (id)initWithFirstName:(NSString *)aFirstName lastName:(NSString *)aLastName {
return [self initWithFirstName:aFirstName lastName:aLastName dateOfBirth:nil];
}
你也可以实现一个标准的init方法来提供合适的默认值:
- (id)init {
return [self initWithFirstName:@"John" lastName:@"Doe" dateOfBirth:nil];
}
如果你需要编写一个子类化一个类时初始化方法,使用多个init方法,你应该覆盖超类的指定初始化执行自己的初始化,或者添加自己的额外的初始化。不管怎样,你应该调用超类的指定初始化(代替[super init];)之前做任何自己的初始化。
你可以实现定制的访问器方法
属性不总是由自己的实例变量。
作为一个例子,XYZPerson类可能会定义一个只读属性对一个人的全名:
@property (readonly) NSString *fullName;
与其每次更改姓名时都要更新fullName属性,不如编写一个自定义访问器方法来根据请求构建全名字符串:
- (NSString *)fullName {
return [NSString stringWithFormat:@"%@ %@", self.firstName, self.lastName];
}
这个简单的示例使用格式字符串和说明符(如前一章所述)构建一个字符串,其中包含用空格分隔的人名和姓。
注意:虽然这是一个方便的示例,但必须认识到它是特定于地区的,并且只适用于将人名放在姓之前的国家。
如果需要为确实使用实例变量的属性编写自定义访问器方法,则必须从方法中直接访问该实例变量。例如,通常使用“延迟访问器”来延迟属性的初始化,如下所示:
- (XYZObject *)someImportantObject {
if (!_someImportantObject) {
_someImportantObject = [[XYZObject alloc] init];
}
return _someImportantObject;
}
在返回值之前,该方法首先检查_someImportantObject实例变量是否为nil;如果是,则分配一个对象。
注意:编译器会自动合成一个实例变量,在所有情况下,它也会合成至少一个访问器方法。如果你同时为readwrite属性实现了getter和setter,或者为readonly属性实现了getter,编译器会认为你控制了属性实现,而不会自动合成实例变量。
如果你仍然需要一个实例变量,你需要请求一个被合成:
@synthesize property = _property;
属性默认是原子的
默认情况下,Objective-C属性是原子性的:
@interface XYZObject : NSObject
@property NSObject *implicitAtomicObject; // atomic by default
@property (atomic) NSObject *explicitAtomicObject; // explicitly marked atomic
@end
这意味着合成的访问器确保一个值总是由getter方法完全检索,或者通过setter方法完全设置,即使访问器是从不同的线程同时调用的。
因为原子访问方法的内部实现和同步是私有的,所以不可能将合成的访问方法与自己实现的访问方法组合在一起。例如,如果尝试为原子的readwrite属性提供自定义setter,而让编译器来合成getter,则会得到编译器警告。
你可以使用nonatomic属性来指定合成的访问器只是简单地设置或直接返回一个值,而不能保证从不同的线程同时访问相同的值会发生什么。因此,访问非原子属性要比访问原子属性快得多,而且可以将合成的setter与自己的getter实现相结合:
@interface XYZObject : NSObject
@property (nonatomic) NSObject *nonatomicObject;
@end
@implementation XYZObject
- (NSObject *)nonatomicObject {
return _nonatomicObject;
}
// setter will be synthesized automatically
@end
注意:属性原子性并不等同于对象的线程安全性。
考虑一个XYZPerson对象,其中使用来自一个线程的原子访问器来更改一个人的姓和名。如果另一个线程同时访问这两个名称,原子getter方法将返回完整的字符串(不会崩溃),但不能保证这些值是彼此相关的正确名称。如果在更改之前访问了姓氏,而在更改之后访问了姓氏,那么你将得到不一致的、不匹配的名称对。
这个示例非常简单,但是当考虑跨一个相关对象网络时,线程安全问题就变得复杂得多。线程安全在并发编程指南中有更详细的介绍。
通过所有权和职责管理对象图
正如你已经看到的,Objective-C对象的内存是动态分配的(在堆上),这意味着你需要使用指针来跟踪对象的地址。与标量值不同,不可能总是通过一个指针变量的作用域来确定对象的生存期。相反,一个对象必须在内存中保持活动状态,直到其他对象需要它为止。
与其手动管理每个对象的生命周期,不如考虑对象之间的关系。
例如,对于XYZPerson对象,firstName和lastName的两个字符串属性实际上是由XYZPerson实例“拥有”的。这意味着只要XYZPerson对象还在内存中,它们就应该留在内存中。
当一个对象以这种方式依赖于其他对象,从而有效地获得那些其他对象的所有权时,第一个对象被称为对其他对象的强引用。在Objective-C中,一个对象只要有至少一个来自另一个对象的强引用,它就会保持活动状态。XYZPerson实例和两个NSString对象之间的关系如图3-2所示。
图3-2强引用strong的关系
当XYZPerson对象从内存中释放时,两个string对象也将被释放,假设没有其他强引用留给它们。
要为这个示例增加一点复杂性,请考虑图3-3所示的应用程序的对象图。
图3-3 Name Badge Maker应用程序
当用户单击Update按钮时,将使用相关的名称信息更新徽章预览。
第一次输入个人详细信息并单击update按钮时,简化的对象图可能如图3-4所示。
图3-4初始创建XYZPerson的简化对象图
当用户修改此人的名字时,对象图将更改为如图3-5所示。
图3-5更改人名时的简化对象图
尽管XYZPerson对象现在有了一个不同的名,但是badge显示视图仍然与原来的@“John”字符串对象保持着紧密的关系。这意味着@“John”对象留在内存中,由badge视图用于打印名称。
一旦用户再次单击Update按钮,badge视图将被告知更新其内部属性以匹配person对象,因此对象图如图3-6所示。
图3-6更新badge视图后的简化对象图
此时,原来的@“John”对象不再有任何对它的强引用,因此从内存中删除了它。
默认情况下,Objective-C属性和变量都保持对其对象的强引用。这对于许多情况来说都是可以的,但是它确实会导致强引用循环的潜在问题。
避免强引用循环
尽管强引用适用于对象之间的单向关系,但是在处理相互连接的对象组时需要小心。如果一组对象由一个强大的关系圈连接,即使没有来自组外的强大引用,它们也会保持彼此的活力。
表视图对象(iOS的UITableView和OS X的NSTableView)及其委托之间存在一个明显的潜在引用循环。为了使泛型表视图类在多种情况下都有用,它将一些决策委托给外部对象。这意味着它依赖于另一个对象来决定显示什么内容,或者当用户与表视图中的特定条目交互时该做什么。
一个常见的场景是,table视图有一个对其委托的引用,而该委托有一个对table视图的引用,如图3-7所示。
图3-7表视图与其委托之间的强引用
如果其他对象放弃与表视图和委托的强关系,就会出现问题,如图3-8所示。
图3-8强引用循环
即使不需要将对象保存在内存中—除了两个对象之间的关系之外,与表视图或委托之间没有强关系—其余的两个强关系也使两个对象保持活动状态。这就是所谓的强参考循环。
解决这个问题的方法是一个强引用一个弱引用代替。弱引用并不意味着两个对象之间的所有权或责任,也不保证一个对象是活的。
如果表视图被修改为使用与其委托的弱关系(UITableView和NSTableView就是这样解决这个问题的),那么初始对象图现在看起来如图3-9所示。
图3-9表视图与其委托之间的正确关系
当图中的其他对象放弃与表视图的强关系并进行委托时,委托对象就没有强引用了,如图3-10所示。
图3-10避免了强引用循环
这意味着委托对象将被释放,从而释放表视图上的强引用,如图3-11所示。
图3-11释放委托
一旦委托被解除分配,就不再有任何对表视图的强引用,所以它也被解除分配。
使用强声明和弱声明来管理所有权
默认情况下,对象属性声明如下:
@property id delegate;
对它们的合成实例变量使用强引用。若要声明弱引用,请向属性添加一个属性,如下所示:
@property (weak) id delegate;
注意:weak的反义词是strong。不需要显式地指定strong属性,因为它是默认属性。
默认情况下,局部变量(和非属性实例变量)也维护对对象的强引用。这意味着下面的代码将完全按照你的期望工作:
NSDate *originalDate = self.lastModificationDate;
self.lastModificationDate = [NSDate date];
NSLog(@"Last modification date changed from %@ to %@",
originalDate, self.lastModificationDate);
在本例中,局部变量originalDate维护对初始lastModificationDate对象的强引用。当lastModificationDate属性更改时,该属性不再保持对原始日期的强引用,但是原始日期强变量仍然保持该日期的活动状态。
注意:一个变量只有在对象在作用域内,或者直到它被重新赋值给另一个对象或nil时,才会维护对该对象的强引用。
如果你不想让一个变量保持强引用,你可以把它声明为__weak,就像这样:
NSObject * __weak weakVariable;
因为弱引用不会使对象保持活动状态,所以在引用仍在使用时,可以释放被引用的对象。为了避免一个危险的悬浮指针指向现在deallocate对象最初占用的内存,一个弱引用在它的对象被解除分配时自动设置为nil。
这意味着如果你在前一个日期的例子中使用弱变量:
NSDate * __weak originalDate = self.lastModificationDate;
self.lastModificationDate = [NSDate date];
originalDate变量可能被设置为nil。当self.lastModificationDate被重新分配,该属性不再保持对原始日期的强引用。如果没有其他强引用,原始日期将被解除分配,原始日期将被设置为nil。
弱变量可能会引起混淆,特别是在这样的代码中:
NSObject * __weak someObject = [[NSObject alloc] init];
在本例中,新分配的对象没有对它的强引用,因此它立即被释放,someObject被设置为nil。
注意:与__weak的反义词是__strong。同样,你不需要显式地指定__strong,因为它是默认值。
考虑一个需要多次访问弱属性的方法的影响也很重要,如下所示:
- (void)someMethod {
[self.weakProperty doSomething];
...
[self.weakProperty doSomethingElse];
}
在这种情况下,你可能想要把弱属性缓存在一个强变量中,以确保它在你需要使用它的时候一直保存在内存中:
- (void)someMethod {
NSObject *cachedObject = self.weakProperty;
[cachedObject doSomething];
...
[cachedObject doSomethingElse];
}
在本例中,cachedObject变量维护了对原始弱属性值的强引用,因此只要cachedObject仍然在作用域中(并且没有重新分配另一个值),它就不能被释放。
如果在使用弱属性之前需要确保它不是nil,那么记住这一点尤其重要。仅仅测试它是不够的,就像这样:
if (self.someWeakProperty) {
[someObject doSomethingImportantWith:self.someWeakProperty];
}
因为在多线程应用程序中,属性可能在测试和方法调用之间释放,从而使测试无效。相反,你需要声明一个强局部变量来缓存值,如下所示:
NSObject *cachedObject = self.someWeakProperty; // 1
if (cachedObject) { // 2
[someObject doSomethingImportantWith:cachedObject]; // 3
} // 4
cachedObject = nil; // 5
在本例中,强引用是在第1行创建的,这意味着对象在测试和方法调用时保证是活动的。在第5行,cachedObject被设置为nil,因此放弃了强引用。如果原始对象此时没有其他强引用,那么它将被释放,someWeakProperty将被设置为nil。
对某些类使用不安全的未保留引用
Cocoa和Cocoa Touch中有几个类还不支持弱引用,这意味着你不能声明弱属性或弱局部变量来跟踪它们。这些类包括NSTextView, NSFont和NSColorSpace;有关完整列表,请参见将其转换为ARC说明。
如果需要对其中一个类使用弱引用,则必须使用不安全的引用。对于对象实例属性,这意味着使用unsafe_unretain属性:
@property (unsafe_unretained) NSObject *unsafeProperty;
对于局部变量,你需要使用__unsafe_unretained:
NSObject * __unsafe_unretained unsafeReference;
不安全引用类似于弱引用,因为它不保持相关对象为活动状态,但是如果目标对象被解除分配,则不会将其设置为nil。这意味着你将得到一个悬空指针,指向现在释放定位对象最初占用的内存,因此术语为“不安全”。向悬空指针发送消息将导致崩溃。
复制属性维护它们自己的复制
在某些情况下,对象可能希望保留为其属性设置的任何对象的副本。
例如,图3-4中所示的XYZBadgeView类的类接口可能如下所示:
@interface XYZBadgeView : NSView
@property NSString *firstName;
@property NSString *lastName;
@end
声明了两个NSString属性,它们都保持对其对象的隐式强引用。
考虑一下,如果另一个对象创建了一个字符串,并将其设置为badge视图的属性之一,会发生什么情况,如下所示:
NSMutableString *nameString = [NSMutableString stringWithString:@"John"];
self.badgeView.firstName = nameString;
这是完全有效的,因为NSMutableString是NSString的子类。尽管badge视图认为它在处理一个NSString实例,实际上它在处理一个NSMutableString。
这意味着字符串可以改变:
[nameString appendString:@"ny"];
在本例中,尽管最初为badge视图的firstName属性设置的名称是“John”,但现在是“Johnny”,因为可变字符串已经更改。
你可以选择badge视图应该维护为其firstName和lastName属性设置的任何字符串的副本,以便在设置属性时有效地捕获字符串。
@interface XYZBadgeView : NSView
@property (copy) NSString *firstName;
@property (copy) NSString *lastName;
@end
视图现在维护自己的两个字符串副本。即使设置了一个可变的字符串并随后进行了更改,badge视图也会捕获设置时的任何值。
NSMutableString *nameString = [NSMutableString stringWithString:@"John"];
self.badgeView.firstName = nameString;
[nameString appendString:@"ny"];
这一次,badge视图中持有的firstName将是原始“John”字符串的不受影响的副本。
copy属性意味着该属性将使用强引用,因为它必须保留它创建的新对象。
注意:你希望为copy属性设置的任何对象都必须支持NSCopying,这意味着它应该符合NSCopying协议。
协议在定义消息传递契约的协议中进行描述。有关NSCopying的更多信息,请参见NSCopying或高级内存管理编程指南。
如果你需要直接设置一个copy属性的实例变量,例如在一个初始化方法中,不要忘记设置一个原始对象的副本:
- (id)initWithSomeOriginalString:(NSString *)aString {
self = [super init];
if (self) {
_instanceVariableForCopyProperty = [aString copy];
}
return self;
}
练习
1、修改XYZPerson类中的sayHello方法,以使用该人的名和姓记录问候语。
2、声明并实现一个新的指定初始化器,用于使用指定的名、姓和出生日期以及合适的类工厂方法创建XYZPerson。
不要忘记重写init来调用指定的初始化器。
3、测试如果将一个可变字符串设置为此人的名字会发生什么,然后在调用修改后的sayHello方法之前对该字符串进行修改。通过添加copy属性并再次测试来更改NSString属性声明。
4、尝试使用main()函数中的各种强变量和弱变量创建XYZPerson对象。验证强变量使XYZPerson对象的活动时间至少与预期的一样长。
为了帮助验证一个XYZPerson对象何时被解除分配,你可能需要在XYZPerson实现中提供一个dealloc方法来绑定到对象的生命周期中。当从内存中释放Objective-C对象时,会自动调用此方法,并且通常用于释放手动分配的任何内存,例如通过C malloc()函数,如Advanced memory Management Programming Guide中所述。
出于本练习的目的,重写XYZPerson中的dealloc方法来记录一条消息,如下所示:
- (void)dealloc {
NSLog(@"XYZPerson is being deallocated");
}
尝试将每个XYZPerson指针变量设置为nil,以验证当你期望对象释放时,它们是否被释放。
注意:用于命令行的Xcode项目模板在main()函数中使用@autoreleasepool{}块。为了使用编译器的自动保留计数特性来为你处理内存管理,你在main()中编写的任何代码都必须进入这个自动释放池块。
自动释放池不在本文档的讨论范围内,但是在高级内存管理编程指南中有详细的介绍。
当你在编写Cocoa或Cocoa Touch应用程序而不是命令行工具时,你通常不需要担心创建自己的自动释放池,因为你正在绑定一个对象框架,以确保其中一个已经就绪。
5、修改XYZPerson类描述,以便你可以跟踪配偶或合作伙伴。
你需要仔细考虑对象图管理,决定如何最好地对关系建模。
评论前必须登录!
注册