Objective-C中方法交换的危险是什么?

247

我听说过有人说方法混淆是一种危险的做法。甚至混淆这个名字本身就暗示了它有点欺骗性。

方法混淆是修改映射,使得调用选择器A实际上会调用实现B。其中一个使用场景是扩展闭源类的功能。

我们能否系统化地列出风险,以便任何决定是否使用混淆的人都可以明智地决定是否值得为他们所要做的事而冒这个风险。

例如:

  • 命名冲突: 如果类后来扩展其功能以包括您添加的方法名称,它将会导致大量问题。通过合理命名混淆方法来减少风险。

从你已经阅读过的代码中获得非预期结果已经不是很好的情况了。 - Martin Babacaev
这听起来像是一种不太干净的方式来完成Python装饰器非常干净地完成的工作。 - Claudiu
8个回答

461

我认为这是一个非常好的问题,可惜大多数答案都绕开了真正的问题,只是说不要使用swizzling。

使用方法混合(swizzling)就像在厨房使用锋利的刀具一样。有些人害怕使用锋利的刀具,因为他们认为会严重伤到自己,但事实上 锋利的刀更安全

方法混合可以用于编写更好、更高效、更易维护的代码。但也可能被滥用,导致可怕的错误。

背景

与所有设计模式一样,如果我们充分了解了该模式的后果,我们就能更明智地决定是否使用它。单例是一个很好的例子,它是相当有争议的,原因是它们很难正确实现。尽管如此,仍有许多人选择使用单例。同样的情况也适用于方法混合。只有在你充分了解其优点和缺点后,你才应该形成自己的观点。

讨论

以下是方法混合的一些缺点:

  • 方法混合不是原子性操作
  • 更改了非自己所有的代码的行为
  • 可能存在命名冲突
  • 混合会更改方法的参数
  • 混合顺序很重要
  • 难以理解(看起来是递归的)
  • 难以调试

这些观点都是有道理的,并且通过解决它们,我们可以提高对方法混合的理解以及用于实现结果的方法。我将逐一说明每个观点。

方法混合不是原子性操作

我还没有见过一个安全使用并发的方法混合实现1。但在95%的情况下,你只需要替换一个方法的实现,并希望该实现在程序的整个生命周期中都能被使用。这意味着你应该在+(void)load中进行方法混合。 load 类方法在应用程序启动时串行执行。如果你在这里做方法混合,就不会遇到并发问题。但是,如果你在+(void)initialize中混合,你可能会在混合实现和运行时之间出现竞争条件,从而导致运行时处于奇怪的状态。

更改了非自己所有的代码的行为

这是一个关于方法替换的问题,但这也是方法替换的重点所在。目标就是能够改变那些代码。人们认为这很重要的原因在于,你不仅仅是为了一个想改变的NSButton实例而改变了它,而是为了应用程序中所有的NSButton实例都改变了。因此,在进行方法替换时应该谨慎,但并不需要完全避免。

可以这样理解...如果你覆盖一个类中的方法并且没有调用父类的方法,可能会导致问题出现。在大多数情况下,父类希望被调用(除非有文档说明)。如果您将相同的思想应用于方法替换,就能避免大部分问题。始终调用原始实现。如果不这样做,您可能会改变太多以至于不安全。

可能存在的命名冲突

在Cocoa中,命名冲突一直是一个问题。我们经常在分类中添加类名称和方法名称前缀。不幸的是,在我们的语言中,命名冲突是一种瘟疫。但是,在涉及到方法替换时,它们不必成为问题。我们只需要稍微改变一下对方法替换的想法。大多数方法替换都是这样完成的:

@interface NSView : NSObject
- (void)setFrame:(NSRect)frame;
@end

@implementation NSView (MyViewAdditions)

- (void)my_setFrame:(NSRect)frame {
    // do custom work
    [self my_setFrame:frame];
}

+ (void)load {
    [self swizzle:@selector(setFrame:) with:@selector(my_setFrame:)];
}

@end

这个方法运行良好,但是如果 my_setFrame:在其他地方定义会发生什么?这个问题并不是独特于交换方法实现的,但是我们可以通过一些方式解决。此解决方法还有一个附加优势,可以解决其他潜在问题。这里是我们采取的替代方案:

@implementation NSView (MyViewAdditions)

static void MySetFrame(id self, SEL _cmd, NSRect frame);
static void (*SetFrameIMP)(id self, SEL _cmd, NSRect frame);

static void MySetFrame(id self, SEL _cmd, NSRect frame) {
    // do custom work
    SetFrameIMP(self, _cmd, frame);
}

+ (void)load {
    [self swizzle:@selector(setFrame:) with:(IMP)MySetFrame store:(IMP *)&SetFrameIMP];
}

@end

虽然这看起来有点不像Objective-C(因为它使用了函数指针),但它避免了任何命名冲突。从原理上讲,它与标准交换操作做的是完全相同的事情。对于一直在使用旧定义的人来说,这可能是一个小小的改变,但最终我认为这是更好的方法。交换方法定义如下:

typedef IMP *IMPPointer;

BOOL class_swizzleMethodAndStore(Class class, SEL original, IMP replacement, IMPPointer store) {
    IMP imp = NULL;
    Method method = class_getInstanceMethod(class, original);
    if (method) {
        const char *type = method_getTypeEncoding(method);
        imp = class_replaceMethod(class, original, replacement, type);
        if (!imp) {
            imp = method_getImplementation(method);
        }
    }
    if (imp && store) { *store = imp; }
    return (imp != NULL);
}

@implementation NSObject (FRRuntimeAdditions)
+ (BOOL)swizzle:(SEL)original with:(IMP)replacement store:(IMPPointer)store {
    return class_swizzleMethodAndStore(self, original, replacement, store);
}
@end

重命名方法的混淆会改变方法的参数

在我看来,这是最重要的一点。这就是标准方法混淆不应该被执行的原因。您正在更改传递给原始方法实现的参数。这就是它发生的地方:

[self my_setFrame:frame];

这行代码的作用是:

objc_msgSend(self, @selector(my_setFrame:), frame);

使用运行时查找my_setFrame:的实现。一旦找到了实现,它将使用相同的参数调用该实现。它找到的实现是setFrame:的原始实现,因此它继续调用该实现,但是_cmd参数不是像应该的那样setFrame:,而是my_setFrame:。原始实现被调用时带有它从未预期接收到的参数。这不好。

有一个简单的解决方案— 使用上面定义的备选交换技术。参数将保持不变!

交换顺序的重要性

方法交换的顺序很重要。假设只有NSView上定义了setFrame:,请想象以下事情发生的顺序:

[NSButton swizzle:@selector(setFrame:) with:@selector(my_buttonSetFrame:)];
[NSControl swizzle:@selector(setFrame:) with:@selector(my_controlSetFrame:)];
[NSView swizzle:@selector(setFrame:) with:@selector(my_viewSetFrame:)];
NSButton的方法被篡改时会发生什么呢?大多数篡改都将确保不替换所有视图的setFrame:实现,因此它将使用现有实现来重新定义NSButton类中的setFrame:,以便交换实现不会影响所有视图。现有实现是在NSView上定义的实现。当在NSControl上进行篡改时(再次使用NSView实现),同样的事情也会发生。

当您在按钮上调用setFrame:时,它将调用您的篡改方法,然后直接跳转到最初在NSView上定义的setFrame:方法。不会调用NSControlNSView篡改实现。

但如果顺序是什么:

[NSView swizzle:@selector(setFrame:) with:@selector(my_viewSetFrame:)];
[NSControl swizzle:@selector(setFrame:) with:@selector(my_controlSetFrame:)];
[NSButton swizzle:@selector(setFrame:) with:@selector(my_buttonSetFrame:)];

由于视图交换是首先发生的,因此控制器交换将能够提起正确的方法。同样,由于控制器交换在按钮交换之前,按钮将提取控制器交换的setFrame:实现。这有点令人困惑,但这是正确的顺序。我们如何确保这个顺序?

再次,只需使用load来交换事物。如果你在load中进行交换,并且你只对正在加载的类进行更改,那么你就会很安全。load方法保证超类的load方法将在任何子类之前被调用。我们将得到完全正确的顺序!

难以理解(看起来递归)

观察传统定义的交换方法,我认为很难理解发生了什么。但是,观察以上替代的交换方式,就很容易理解了。这个问题已经解决了!

难以调试

调试时的困惑之一是看到一个奇怪的回溯,在其中交换名称混合在一起,让你的头脑一片混乱。同样,替代实现解决了这个问题。你将在回溯中看到明确命名的函数。尽管如此,交换仍然很难调试,因为很难记住交换所产生的影响。好好记录你的代码(即使你认为只有你自己会看到它)。遵循良好的实践,你就没问题了。这并不比多线程代码更难调试。

结论

如果使用得当,则方法交换是安全的。一个简单的安全措施是只在load中进行交换。像编程中的许多其他事物一样,它可能是危险的,但理解其后果将允许你正确地使用它。


1使用上述定义的交换方法,如果你使用跳板,你可以使事物变得线程安全。你需要两个跳板。在方法的开始处,你需要将函数指针store分配给一个旋转直到store指向的地址改变的函数。这将避免在设置store函数指针之前调用交换方法的任何竞争条件。然后,在实现没有在类中已经被定义的情况下,你需要使用跳板,并且以适当方式查找并调用超类方法。定义方法,使其动态查找超级实现,将确保交换调用的顺序无关紧要。


27
非常具有信息量和技术水平的答案。讨论非常有趣。感谢你抽出时间写这篇文章。 - Ricardo Sanchez-Saez
您能否指出在您的经验中,应用商店是否接受交换方法(swizzling)?我也向您推荐查看https://dev59.com/BWoy5IYBdhLWcg3wA5aC。 - Dickey Singh
3
交换方法可以用于App Store上。许多应用程序和框架都使用它(包括我们自己的应用程序)。上面的所有内容仍然适用,但不能交换私有方法(实际上,从技术上讲,你可以这样做,但会冒被拒绝的风险,相关危险性请参考其他线程)。 - wbyoung
非常棒的答案。但是为什么要在class_swizzleMethodAndStore()中进行交换,而不是直接在+swizzle:with:store:中进行呢?为什么需要这个额外的函数? - Frizlab
2
@Frizlab 很好的问题!这实际上只是一个风格问题。如果你正在编写一堆直接使用 Objective-C 运行时 API (以 C 语言编写) 的代码,那么为了保持一致性,用 C 风格来调用它会更好。我能想到除此之外的唯一原因是,如果你用纯 C 编写了某些内容,那么这些内容仍然可被调用。但是,在 Objective-C 方法中完成所有操作也完全没有问题。 - wbyoung
不错的写作。这位时髦的人也在这个主题上发表了一篇文章。http://nshipster.com/method-swizzling/ - Robert

12

首先,我将准确地定义我所说的方法交换:

  • 将最初发送到一个方法(称为A)的所有调用重新路由到一个新方法(称为B)。
  • 我们拥有方法B
  • 我们不拥有方法A
  • 方法B执行一些工作,然后调用方法A。

方法交换比这更普遍,但这是我感兴趣的情况。

危险:

  • 原始类的更改。我们不拥有要交换的类。如果该类发生更改,我们的交换可能会停止工作。

  • 难以维护。不仅需要编写和维护交换方法,还需要编写和维护执行交换的代码

  • 难以调试。跟踪交换的流程很困难,有些人甚至可能没有意识到进行了交换。如果由于原始类的更改引入了漏洞,它们将很难解决。

总之,您应该尽量减少方法交换,并考虑原始类的更改可能如何影响您的交换。此外,您应该清楚地注释和记录自己在做什么(或完全避免使用它)。


@所有人 我已经将你们的答案整合成一个漂亮的格式。请随意编辑/添加。感谢你们的贡献。 - Robert
我是你心理学的粉丝。“伴随着强大的交换能力而来的是巨大的交换责任。” - Jacksonkr

7

实际上,交换方法本身并不是真正危险的。问题在于,正如你所说,它经常用于修改框架类的行为。这意味着你假设了解那些私有类的工作方式,这是“危险”的。即使你的修改今天有效,也有可能苹果在未来更改类,导致你的修改失效。此外,如果许多不同的应用程序都这样做,那么苹果要更改框架而不破坏大量现有软件的难度就会增加。


我认为这样的开发人员应该自食其果——他们将代码紧密耦合到特定的实现上。因此,如果实现以微妙的方式发生变化,导致代码出现问题,那是他们的错,如果不是他们明确决定将代码耦合得如此紧密,就不会出现这种情况。 - Arafangion
当然,但是假设一个非常流行的应用在下一个iOS版本中出现问题。很容易说这是开发者的错,他们应该知道得更好,但这会让用户对苹果产生不良印象。我认为如果你想这样做,除了可能使代码有些混乱之外,swizzling自己的方法甚至类没有任何害处。当你交换由别人控制的代码时,情况开始变得更加危险 - 这正是swizzling最诱人的地方。 - Caleb
苹果不是唯一提供可通过刺激修改的基类的提供者。您的回答应该被编辑为包含所有人。 - A.R.
@A.R. 或许是这样,但问题标记为iOS和iPhone,所以我们应该考虑在这个背景下。鉴于iOS应用程序必须静态链接除iOS提供的库和框架之外的任何库和框架,因此苹果实际上是唯一可以在没有应用程序开发人员参与的情况下更改框架类实现的实体。但我确实同意类似问题也会影响其他平台,并且我认为这些建议也可以推广到交换等方面。总的来说,建议是:不要动他人维护的组件。 避免修改其他人维护的组件。 - Caleb
方法混合可能会非常危险。但随着新框架的推出,它们并不完全省略以前的框架。您仍然需要更新应用程序所期望的基本框架。而且这个更新会破坏你的应用程序。当 iOS 更新时,这可能不是一个很大的问题。我的使用模式是覆盖默认的 reuseIdentifier。由于我无法访问 _reuseIdentifier 变量,并且不想替换它,除非它没有提供,我的唯一解决方案是对每个子类进行子类化并提供重用标识符,或者在空值时进行 swizzle 和 override。实现类型是关键... - The Lazy Coder

5

如果使用得当,它可以导致优雅的代码,但通常只会导致混乱的代码。

我认为除非你知道它对于特定的设计任务提供了非常优雅的机会,否则应该禁止使用它,但你需要清楚地知道它为什么适用于该情况,以及为什么其他替代方案不能为该情况提供优雅的解决方案。

例如,方法交换的一个很好的应用是isa交换,这是ObjC实现键值观察的方式。

一个糟糕的例子可能是依赖方法交换作为扩展类的手段,这会导致极高的耦合。


1
在方法交换的上下文中提到KVO时,它的值为-1。isa交换只是另一件事情。 - Nikolai Ruhe
@NikolaiRuhe:请随意提供参考资料,让我受益匪浅。 - Arafangion
这里提供了KVO实现细节的参考链接:http://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/KeyValueObserving/Articles/KVOImplementation.html#//apple_ref/doc/uid/20002307-BAJEAIEE。CocoaDev上有关于方法交换的描述:http://cocoadev.com/wiki/MethodSwizzling。 - Nikolai Ruhe

5

尽管我用过这种技术,但我想指出:

  • 它会使您的代码模糊化, 因为它可能会导致未记录的、但是希望的副作用。当一个人阅读代码时,他/她可能不知道所需的副作用行为,除非他/她记得搜索代码库以查看是否已经进行了方法交换。我不确定如何缓解这个问题,因为并不总是可能记录代码依赖于副作用交换行为的每个地方。
  • 它可能会使您的代码可重用性降低,因为发现某个代码段依赖于他们想要在其他代码库中使用的交换行为的人,不能简单地将其剪切并粘贴到其他代码库中,而还必须找到并复制交换后的方法。

4

我认为最大的危险在于无意中创造出许多不必要的副作用。这些副作用可能表现为“错误”,从而使您走上错误的解决方案之路。根据我的经验,危险在于难以阅读、混乱和令人沮丧的代码。就像某人在C++中过度使用函数指针一样。


好的,问题在于很难调试。这个问题是由于审阅者没有意识到/忘记了代码已经被篡改,因此在调试时看错了地方。 - Robert
3
基本上是这样。而且,当过度使用时,你可能会陷入无尽的 swizzles 链或网络中。想象一下,如果将来某个时候改变其中一个会发生什么?我把这种经历比作叠茶杯或玩 “代码 Jenga”。 - A.R.

4

您可能最终会得到看起来奇怪的代码,例如:

- (void)addSubview:(UIView *)view atIndex:(NSInteger)index {
    //this looks like an infinite loop but we're swizzling so default will get called
    [self addSubview:view atIndex:index];

源自一些与UI技巧相关的实际生产代码。


3

方法混淆在单元测试中非常有用。

它允许您编写模拟对象并使用该模拟对象代替真实对象。这样您的代码可以保持整洁,您的单元测试具有可预测的行为。例如,您想要测试一些使用CLLocationManager的代码。您的单元测试可以混淆startUpdatingLocation方法,以便将预定的一组位置提供给您的委托,并且您的代码无需更改。


isa-swizzling会是一个更好的选择。 - vikingosegundo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接