译文 · 原文: Friday Q&A 2011-08-05: Method Signature Mismatches · 作者 Mike Ash
原文:https://www.mikeash.com/pyblog/friday-qa-2011-08-05-method-signature-mismatches.html 发布:2011-08-05 作者:Mike Ash 译者:MiMo(mimo-v2.5-pro);代码块保留英文原样
某天你正愉快地写着代码,点击构建,突然出现了令人胆寒的警告:warning: no ‘-fooMessage’ method found (Messages without a matching method signature will be assumed to return ‘id’ and accept ’…’ as arguments.)。你仔细检查代码,方法名完全正确,于是耸耸肩继续工作。几小时后,程序开始出现诡异的异常行为。究竟发生了什么?今天我将探索 Objective-C 方法签名不匹配的神秘世界,这个主题由一位匿名读者提出。
消息发送回顾
每当在代码中编写 [obj foo] 消息发送时,编译器会在内部将其转换为对以下函数的标准调用:
id objc_msgSend(id self, SEL op, ...);这个函数有几个变体,用以处理诸如结构体返回(struct returns)和调用 super(calls to super)等情况,但它们本质上都执行相同的操作。
尽管调用本身是常规的,但这个函数本身却奇特且不同寻常。当被调用时,它使用 self 和 SEL 参数来查找需要调用的方法。一旦找到该方法,它就会跳转到该方法。它完全忽略所有其他参数和返回值。
上面列出的原型直接取自 Apple 的 objc/runtime.h 头文件。它表明该函数接受一个 id,一个 SEL,以及之后的可变参数,并返回 id。这个原型是一个谎言。由于这个函数奇特且不同寻常的特性,它实际上没有固定的原型。objc_msgSend 的正确原型与其将要调用的方法的原型相匹配。这被称为 “方法签名(method signature)”。如果调用方使用的原型与方法本身使用的原型不匹配,就可能会发生一些有趣的事情。
如果你对消息发送(messaging)和 objc_msgSend 的工作原理的更多细节感兴趣,请参阅我之前的文章《Objective-C 消息传递》。
方法签名(Method Signature)
Objective-C 是一门动态类型面向对象语言(dynamically typed object-oriented language),那么方法签名必须匹配到底有什么大不了的呢?你可以将类型为 A 的对象传递给期望类型 B 的代码,只要它行为正确,一切仍然能正常工作。
遗憾的是,这只是一个方面,特别是” Objective”(面向对象)的方面。在” C” 的方面,以上说法都不成立。在那个世界里,类型全都是静态的(static types),而且也没有被严格地强制执行,当你告诉编译器某个东西是类型 A 而它实际上是类型 B 时,编译器就会相信你。如果 A 和 B 在内存中的布局不同,或者在其他方面无法精确匹配,就会引发混乱。
一个方法签名(method signature)就是其参数的类型和返回值的类型。当你在类的 @implementation 中声明一个方法时,它会告诉编译器两条关键信息。首先,它声明了方法的名称,这样该类的使用者就知道存在一个具有该名称的方法。其次,它声明了方法签名,这样调用者就知道如何生成正确的代码来调用它。
不知道方法签名,编译器就不可能生成正确的代码。请考虑下面这个看似无害的调用:
[foo doThing: 0];现在考虑一下该方法的这些不同声明:
- (void)doThing: (double)x; - (void)doThing: (void *)x; - (void)doThing: (char)x;在所有三种情况下,你传递一个 int,它被「implicit conversion(隐式转换)」为不同的类型。这种隐式转换发生在「call site(调用点)」。如果 doThing: 期望一个 double 参数,那么参数必须在调用之前就被转换为 double。如果「compiler(编译器)」不知道参数类型,它就不会知道需要进行转换。结果是,调用者将 int 放置在一个位置,而调用者从一个完全不同的位置获取 double,导致无意义的数据。
「ABI(应用二进制接口)」究竟发生什么,取决于你所运行的特定架构上函数调用的底层细节。这些细节记录在相关架构的 ABI 或应用二进制接口中。这些细节在不同架构之间会完全不同。例如,i386、x86-64 和 ARM 都使用完全不同的「function calling conventions(函数调用约定)」。在本文章中,我将专注于 ARM,因为它是其中最简洁的。这些原则也适用于其他架构,即使细节不同。
ARM 的函数调用规范可以从 ABI(应用二进制接口)文档的第 15 页开始查阅,该文档可在此处获取:
http://infocenter.arm.com/help/topic/com.arm.doc.ihi0042d/IHI0042D_aapcs.pdf
其中许多细节在此并不重要,但有些关键点需要了解。
函数的前四个参数通过寄存器 r0-r3 传递,每个寄存器可容纳 32 位数据。如果函数参数超过四个,额外参数将通过压入栈来传递。但这假设参数是 32 位数据类型。对于 64 位数据类型如long long和double,相关参数会占用两个寄存器(或两个栈位置),并且需要对齐到偶数位置开始存放。
返回值通过存入 r0 来传递。同样,这也假设返回值是 32 位数据类型。64 位数据类型会通过 r0 和 r1 共同返回。对于超过 32 位的结构体(struct),返回方式是:调用方预先分配内存来存储返回值,然后将该内存地址作为一个额外的隐藏参数传递给函数。
C 标准同样在此发挥作用。当编译器完全找不到方法签名时,它会假设该方法签名为 …(可变参数)形式。C 语言规定了可变参数的类型提升规则:任何小于 int 的整型都会被提升为 int,而 float 类型的参数会被提升为 double。
后果
现在我们了解了参数是如何传递的,但这意味着什么呢?
方法签名不匹配最常见的情况是:该方法的声明完全不可见,编译器于是假设它接受 … 可变参数。这通常发生在忘记导入包含该声明的头文件时。如前所述,当参数作为可变参数传递时,类型会被提升。
对于整型而言,至少在 ARM 架构上,这是无害的。所有小于等于 int 的整型都通过单个寄存器或栈槽位传递,调用方最终能正确提取数据中对应的部分。
在浮点类型(floating point types)的情况下,如果方法接受一个 float(浮点数),这会导致一个主要问题。float 将被提升为 double(双精度浮点数),这会占用两个寄存器或栈槽。方法不仅只会加载一半的数据(这本身也不会有任何意义),而且所有后续参数都会被向下移位一个,导致方法为所有这些参数也获取垃圾数据。如果 float 是一个奇数编号的参数,double 会被进一步推下以正确对齐。
如果调用者传递的数字类型与方法期望的类型不同,还可能引发一个主要问题。C 语言会在数字类型之间进行静默转换。通常情况下,如果方法期望一个 float(浮点数)但调用者传递了一个 int(整数),编译器会静默地将该值转换为 float,一切正常。然而,如果没有可用的方法签名,编译器将直接传递该 int。随后,方法会尝试将这个位模式解释为一个 float,从而产生无意义的结果。如果双方使用的数据类型大小不同,例如传递一个 int 却期望一个 double,或者传递一个 char 却期望一个 long long,情况会变得更糟。这不仅会导致提取出错误的数据,而且还会再次使所有后续参数发生偏移,从而导致它们最终也获得错误的数据。
最后,返回类型冲突也可能产生问题。当没有方法声明存在时,编译器会假定该方法返回 id 类型。如果你使用了返回值,那么任何冲突都会很快变得明显。如果你将返回值当作 int 处理,编译器会报错提示类型不匹配。
问题出现的时机在于你不使用返回值。如果方法返回的是指针或整数,那么一切正常。然而如果方法返回的是大型结构体,编译器会插入一个额外的隐藏参数(hidden parameter)来存放调用方为该结构体分配的存储空间地址,这将导致后续所有参数移位,使得所有参数都包含垃圾数据。若不巧方法确实执行了返回操作,它会将返回值写入你第一个参数所指向的位置 —— 这几乎肯定不是你想要的结果。在某些架构上,对于特定浮点类型必须使用 objc_msgSend_fpret 变体,此处的类型不匹配可能导致后续出现诡异的崩溃,因为 CPU 的浮点状态会因此异常。
(需注意:语言规范本身并不保证在方法期望固定参数时,传递可变参数能正常工作。这种情况之所以通常在你可能遇到的架构上可行,只是巧合罢了。)
这种情况也阻止了编译器检查你的代码。即使你只使用安全的数据类型,如果你意外地为一个期望接收 int 参数的方法传递了 NSNumber *,编译器也无法发出警告。这将导致对象的指针值被解释为一个数字,而这并非你的本意。如果有了方法签名(method signature),编译器就会直接报错。
更少见的情况是存在真正的方法签名不匹配。当调用方看到的声明与方法实现不同时,就会发生这种情况。这要求代码中存在两个名称相同但签名不同的方法。这种冲突可能发生在:一个对象变量被声明为某种类型,但实际存储的对象却是另一种类型。也可能在使用 id 类型变量时发生 —— 要么编译器只看到了错误的声明,要么它看到了多个声明却选择了错误的那个。虽然编译器在必须从多个不同声明中进行选择时会发出警告,但当它只看到错误声明的情况尤其棘手,因为编译器没有任何迹象表明存在问题,因此无法给出任何警告。
让我们看一个例子。以下是两个在不同类中声明的方法:
@interface Foo (MoreMethods) - (void)makeThingOfWidth: (int)width; @end
@interface Bar (MoreMethods) - (void)makeThingOfWidth: (float)width; @end这两个方法虽然拥有完全相同的名称,却具有不同的方法签名。如果我们编写的代码同时导入了这两个头文件,那么当试图对一个 id 类型的对象使用这些方法时,程序就会遇到麻烦:
id obj = ...; [obj makeThingOfWidth: 42]; // danger!根据 obj 是 Foo 还是 Bar,以及编译器决定使用哪个方法签名,这最终可能将垃圾数据传入该方法。幸运的是,编译器会对这种情况发出警告。
更危险的是仅导入一个头文件,却最终操纵另一个类实例的代码:
#import <Foo.h> // note: no Bar.h
id obj = [otherObj getMeABar]; [obj makeThingOfWidth: 42];这是一定会出问题的,而且编译器无法生成任何错误或警告,因为在编译器的视角下,根本不存在潜在的问题。
通常情况下,为了避免这种情况以及更温和的、多个签名可见的情况,我建议尽量在切实可行的范围内,让具有不同签名的方法也拥有不同的名称。当无法做到这一点时,使用静态类型(static types)而非 id 要么能解决问题,要么至少会给出一个错误或警告,表明有地方出错了。
结论 方法签名不匹配(method signature mismatches)可能导致一些真正奇怪的行为,从方法参数中出现怪异的数据,到看似随机的崩溃,而这些崩溃可能直到签名不匹配发生很久之后才会出现。
优秀的 Objective-C 代码应该总是能在无警告的情况下构建,而这恰恰是一个说明为什么这一点很重要的绝佳例子。编译器几乎会对所有可能导致方法签名不匹配的情况发出警告,而当它发出警告时,就需要修复问题以消除警告。
在某些罕见情况下,不匹配可能发生而不生成警告。幸运的是,这种情况很难进入,不太可能意外发生。然而,由于 Objective-C 对于具有相同名称但不同签名(signatures)的多个方法处理得很差,因此最好尽量确保具有不同签名的方法也具有不同的名称。今天的内容就到这里。两周后再回来参加另一个周五 Q & A。一如既往,周五 Q & A 由您,读者驱动。如果您有任何建议的话题希望在这里看到,请发送过来!
Original (English)
Source: https://www.mikeash.com/pyblog/friday-qa-2011-08-05-method-signature-mismatches.html
You’re happily writing code one day, click Build, and suddenly the dreaded warning appears: warning: no ‘-fooMessage’ method found (Messages without a matching method signature will be assumed to return ‘id’ and accept ’…’ as arguments.) You double-check your code and your method name is correct, so you shrug and move on. A few hours later, your program starts misbehaving strangely. What’s going on? Today, I’ll explore the mysterious world of Objective-C method signature mismatches, a topic suggested by an anonymous reader.
Message Sending RecapWhenever you write a [obj foo] message send in your code, the compiler internally translates it to a standard function call to this function:
id objc_msgSend(id self, SEL op, ...);There are a few variants of this, to handle things like struct returns and calls to super, but they all do essentially the same thing.
While the call itself is normal, this function is strange and unusual. When called, it uses the self and SEL parameters to look up the method that needs to be invoked. Once it finds the method, it jumps to it. It completely ignores all other arguments and the return value.
The prototype listed above is taken directly from Apple’s objc/runtime.h header. It shows that the function takes an id, a SEL, and variable arguments after that, and that it returns id. This prototype is a lie. Because of the strange and unusual nature of this function, it actually has no fixed prototype. The correct prototype for objc_msgSend is whatever matches the prototype of the method that it will invoke. This is what’s referred to as the “method signature”. If the prototype used by the caller doesn’t match the prototype used by the method itself, interesting things can happen.
If you’re interested in more details about precisely how messaging and objc_msgSend work, see my previous article, Objective-C Messaging.
Method SignaturesObjective-C is a dynamically typed object-oriented language, so what’s the big deal with method signatures having to match? You can pass an object of type A to code that expects type B, and as long as it behaves properly, everything still works.
Unfortunately, that’s only half the story, specifically the “Objective” half. In the “C” half, none of the above is true. In that world, types are all static and not all that well enforced, and when you tell the compiler that something is of type A when it’s really of type B, it believes you. If A and B lay things out differently in memory, or otherwise don’t precisely match, havoc ensues.
A method signature is simply the types of the arguments and the return type. When you declare a method in your class’s @implementation, it gives the compiler two key pieces of information. First, it declares the method’s name so that users of the class know that a method by that name exists. Second, it declares the method’s signature, so that callers know how to generate the correct code to call it.
It’s not possible for the compiler to generate correct code without knowing the method signature. Consider the following fairly innocuous call:
[foo doThing: 0];Now consider these different declarations for that method:
- (void)doThing: (double)x; - (void)doThing: (void *)x; - (void)doThing: (char)x;In all three cases, you’re passing an int which gets implicitly converted to a different type. That implicit conversion happens at the call site. If doThing: expects a double argument, then the argument must be converted to a double before the call is even made. If the compiler doesn’t know the argument type, it won’t know that it needs to make the conversion. The result is that the caller will place an int in one place and the caller will fetch a double from a completely different place, resulting in nonsensical data.
ABIsWhat exactly happens in cases like this depends on the low-level details of how functions are called on the particular architecture you’re running on. Those details are documented in the ABI, or application binary interface, of the architecture in question. The details will be completely different from one architecture to the next. For example, i386, x86-64, and ARM, all use completely different function calling conventions. For this article, I’ll concentrate on ARM, as it’s the cleanest of the bunch. The principles carry over to other architectures, even if the details don’t.
The function calling conventions for ARM can be found starting on page 15 of the ABI, which is available here:
http://infocenter.arm.com/help/topic/com.arm.doc.ihi0042d/IHI0042D_aapcs.pdf
Many of the details aren’t too important here, but some are key.
The first four arguments to a function are passed in registers r0-r3, which each hold 32 bits. If the function takes more than four arguments, additional arguments are passed by pushing them onto the stack. However, this assumes that the arguments are 32-bit data types. For 64-bit data types such as long long and double, the argument in question takes up two register (or two stack slots), and is additionally aligned to start in an even-numbered position.
Values are returned by storing them into r0. Again, this assumes a 32-bit data type. 64-bit data types, are returned in r0 and r1. Structs which are larger than 32-bits are returned by having the caller allocate memory to store the return value, then passing an extra, hidden parameter to the function which contains the address of that memory.
The C standard also comes into play. When the compiler can’t find a method signature at all, it assumes that the method signature takes … variable arguments. C specifies type promotions for variable arguments. Any integer type smaller than an int is promoted to an int, and arguments of type float are promoted to double.
ConsequencesNow we know how parameters are passed, but what does it mean?
The most common case for a method signature mismatch is when a declaration for the method isn’t visible at all, and the compiler then assumes that it takes … variable arguments. This usually happens when forgetting to import the header which contains the declaration. As noted above, types get promoted when passed as variable arguments.
In the case of integer types, this is harmless, at least on ARM. All integer types up to int are passed in a single register or stack slot, and the caller will end up extracting the correct portion of the data.
In the case of floating point types, this causes a major problem if the method takes a float. The float will be promoted to a double, which takes up two registers or stack slots. Not only will the method only load half of the data (which won’t make any sense on its own anyway), but all subsequent arguments will be shifted down by one, causing the method to fetch junk for all of them as well. If the float is an odd-numbered argument, the double will be pushed down even further in order to align it properly.
It can also cause a major problem if the caller passes a number that’s of a different type than the method is expecting. C will do silent conversions between numeric types. Normally, if the method expects a float but the caller passes an int, the compiler silently converts the value to a float and everything is happy. However, if no method signature is available, the compiler will simply pass the int. The method will then try to interpret that bit pattern as a float, with nonsensical results. It gets worse if the two sides use data types of different sizes, like passing an int where a double is expected, or a char where a long long is expected. This will not only cause bad data to be extracted, but once again shift all of the other arguments down so they end up with bad data as well.
Finally, there can be problems with return type conflicts. When no declaration is present, the compiler assumes that the method returns id. If you’re using the return value, then any conflict will become quickly obvious. If you treat the return value like an int, the compiler will give you an error about the mismatch.
Where it becomes a problem is when you don’t use the return value. If the method returns a pointer or integer, then all is well. However, if the method returns a large struct, an extra hidden parameter will be inserted to hold the address of the caller’s storage for the struct, which will end up shifting everything down and all of the parameters will contain garbage. If you’re unlucky enough to have the method return, it will write its return value to whatever location is indicated by your first parameter, almost certainly not what you want. On some architectures, the objc_msgSend_fpret variant has to be used for some floating point types, and a mismatch here can lead to bizarre crashes later, as the CPU’s floating point state goes bad.
(Note that, in general, there’s no real guarantee made by the language that passing variable arguments will work at all when the method expects fixed arguments. It just so happens that it usually works out on architectures you’re likely to encounter.)
This situation also prevents the compiler from checking your code. Even if you only use data types which are safe, the compiler won’t be able to yell at you if you accidentally pass an NSNumber * for a parameter that expects an int. This will result in the object’s pointer value being interpreted as a number, which is not what you want. With a method signature available, the compiler will give you an error instead.
Less common is the case where there is a true method signature mismatch. This happens when the caller sees a different declaration than the method implementation. This requires two methods with identical names but different signatures to exist in the code. Such a conflict can happen when an object variable is declared to be of one type but the object stored in it is actually a different type. This can also happen when using a variable of type id and either the compiler only sees the wrong declaration or it sees both and chooses the wrong one. While the compiler will warn if it has to choose from multiple distinct declarations, the case where it only sees the wrong one is particularly nasty, as the compiler has no indication that something is wrong, and therefore can’t give any sort of warning.
Let’s look at an example. Here are two methods, declared in two different classes:
@interface Foo (MoreMethods) - (void)makeThingOfWidth: (int)width; @end
@interface Bar (MoreMethods) - (void)makeThingOfWidth: (float)width; @endThese two methods have identical names but different signatures. If we write code which imports both of these headers, code which tries to use these methods on an id will run into trouble:
id obj = ...; [obj makeThingOfWidth: 42]; // danger!Depending on whether obj is a Foo or a Bar, and on which method signature the compiler decides to use, this could end up passing junk data into the method. Fortunately, the compiler will warn for this case.
More dangerous is code which only imports one header, then ends up manipulating an instance of the other class:
#import <Foo.h> // note: no Bar.h
id obj = [otherObj getMeABar]; [obj makeThingOfWidth: 42];This is guaranteed to go wrong, and the compiler can’t generate any errors or warnings about it because, as far as it can see, there’s no potential problem.
In general, to avoid both this and the more benign case where multiple signatures are visible, I recommend trying to ensure that methods with different signatures also have different names whenever it’s practical to do so. When that’s not possible, using static types instead of id will either solve the problem or at least give an error or warning that something has gone wrong.
ConclusionMethod signature mismatches can result in some truly weird behavior, from bizarre data showing up in method arguments to random-looking crashes that don’t occur until long after the site of the mismatch.
Good Objective-C code should always build with no warnings, and this is an excellent example of why this is important. The compiler will warn for nearly all situations that can lead to a method signature mismatch, and when it warns, the problem needs to be fixed in order to eliminate the warning.
In certain rare cases, a mismatch can happen with no warning generated. Fortunately, this is a difficult situation to get into and it’s unlikely to happen by accident. However, because Objective-C deals so poorly with multiple methods which have the same name but different signatures, it’s best to try to ensure that methods with different signatures also have different names.
That wraps things up for today. Come back in another two weeks for another Friday Q&A. As always, Friday Q&A is driven by you, the reader. If you have a suggestion for a topic that you’d like to see covered here, please send it in!