构建带记忆化的 Block 代理

Mike Ash Friday Q&A 中文译文:构建带记忆化的 Block 代理

作者 TommyWu
封面圖片: 构建带记忆化的 Block 代理

译文 · 原文: Friday Q&A 2011-11-11: Building a Memoizing Block Proxy · 作者 Mike Ash

原文:https://www.mikeash.com/pyblog/friday-qa-2011-11-11-building-a-memoizing-block-proxy.html 发布:2011-11-11 作者:Mike Ash 译者:MiMo(mimo-v2.5-pro);代码块保留英文原样


上次,我讨论了自己那个疯狂的 hack—— 它误用了 Objective-C 的消息转发(message forwarding)机制来实现块代理(block proxying)。这种技术允许编写代码插入到任意块之前,以拦截其参数、修改返回值等。今天,我想展示一个几乎接近实用的该 hack 的应用示例。具体来说,我将讨论如何用它构建一个通用的块记忆化(memoization)工具。

记忆化到底是什么?

简而言之,记忆化就是在函数前面加一个缓存(cache)。想象一个纯函数(pure function)—— 也就是说,它没有副作用,并且对于相同的参数总是返回相同的结果。现在假设这个函数的计算成本很高,并且被多次用相同的参数调用。如果对于相同的参数结果总是相同的,那么不断重新计算就是浪费。

标准的解决方案是使用缓存。每当函数计算出一个结果,它就可以将该结果放入一个以参数为键的字典中。每当函数被调用时,它可以先检查字典,看看结果是否已经存在。如果存在,它就可以返回先前计算过的值。

记忆化(memoization)正是在包装函数中执行这种缓存的过程。与其在每个符合此特征的函数中都添加缓存代码,不如将缓存代码编写一次,作为任意函数的包装器。该包装器无需了解函数的具体工作原理,但具备足够的能力来缓存返回值,并在已计算过特定参数集时绕过函数执行。

代码 与之前一样,代码已发布在 GitHub 上: https://github.com/mikeash/MABlockForwarding

方法 先前的探索最终形成了 MAForwardingBlock,它接收一个 block 和一个插桩器(interposer),并返回一个新的 block。当新 block 被调用时,插桩器会首先被调用,并传入一个代表该次调用的 NSInvocation。插桩器随后可以随意操作 NSInvocation,可选择性地调用原始 block,最后将 NSInvocation 的返回值提供给调用者。

这里的目标是编写一个 MAMemoize 函数。这个函数接收一个 block,并返回一个新的 block 来包裹原 block。新的 block 会对原 block 进行记忆化(memoize),根据传入的参数缓存返回值。MAMemoize 将使用 MAForwardingBlock 在原始 block 前进行拦截,并使用一个拦截块(interposer block)来执行记忆化操作。

为了实现这一点,拦截块需要将 NSInvocation 中的所有参数解包到一个可用作字典键的对象中。它还需要能够将返回值与可用作字典值的对象进行双向转换。

一旦它能够完成这些,剩下的部分就很简单了。维护一个包含缓存的字典。解包参数,并检查字典中是否存在对应的条目。如果存在,就取出该参数对应的值并返回。否则,就调用原始 block,然后获取返回值,并在返回前将其存入缓存字典。

实现 首先,我们将定义 MAMemoize 函数。它接收并返回 id 类型,因为它处理的是任意 block,而不仅仅是某个特定类型:

id MAMemoize(id block) {

接下来,创建内存字典。这只是一个局部变量,但它将被 interposer block(介入代码块)捕获,因此最终得以持久存在。Block(代码块)可以有这样有趣的特性。你可以把 block 想象成一个只有一个方法的对象,其中被捕获的变量就是实例变量。

NSMutableDictionary *memory = [NSMutableDictionary dictionary];

现在我们调用 MAForwardingBlock 并传入我们的拦截器(interposer)。该拦截器 block 接收一个 NSInvocation 和一个用于穿透调用至原始 block 的 block。调用过程的开始部分如下:

return MAForwardingBlock(^(NSInvocation *inv, void (^call)(void)) {

从这里开始的所有代码在返回的「block(块)」被调用时运行。inv 参数包含该调用的「NSInvocation(调用对象)」,而 call 块调用原始函数。

首先要做的是将参数提取到一个可以用作「dictionary key(字典键)」的对象中。在这种情况下,我选择使用一个「NSArray(数组)」。虽然这可能不是一个非常高效的字典键,但这段代码本身也不太高效,而且它能很好地完成工作。该数组将用表示每个参数的对象填充。

提取参数还需要知道调用的「method signature(方法签名)」,因此我们同时获取它:

NSMethodSignature *sig = [inv methodSignature];
NSMutableArray *args = [NSMutableArray array];

现在,遍历参数并将它们添加到数组中。注意循环从参数 1 开始,因为参数 0 是隐式的「block(块)」指针参数,不需要包含在缓存中:

for(unsigned i = 1; i < [sig numberOfArguments]; i++)
{

当参数是对象时,任务很简单:使用 getArgument:atIndex: 获取它,然后存入数组。但对于非对象参数,情况就复杂得多。实际上,不可能处理所有可能的类型,因为像指针类型这类东西根本无法被充分内省(introspected)以理解其使用方式。

我的做法是为 C 字符串(C strings)做特殊处理,因为它们似乎是常见的参数类型。对于所有其他参数类型,将原始参数数据读取到一个 NSData 中,并将其直接作为参数处理,甚至不尝试解释其含义。在实践中,这意味着所有基本数据类型都能正常工作,一些结构体以及少数指针(如可以使用 == 比较且生命周期与应用相同的 SEL)也能处理。

首先,我创建一个局部变量 arg 用于存储提取出来的对象(无论它是什么)。同时,我也获取描述此参数类型的字符串:

id arg = nil;
const char *type = [sig getArgumentTypeAtIndex: i];

首先需要检查该参数是否是一个对象。我们可以通过比较 type 字符串的首字符与 @encode(id) 的首字符来实现:

if(type[0] == @encode(id)[0])
{

对于这种情况,只需将参数赋值给 arg。这里我额外添加了一个处理:如果该参数符合 NSCopying 协议,那么在放入数组之前会先进行一次拷贝:

[inv getArgument: &arg atIndex: i];
if([arg conformsToProtocol: @protocol(NSCopying)])
arg = [arg copy];
}

接下来,使用相同技术检查是否为 C 字符串(任何 char * 参数均假设为 C 字符串)。如果是,则将其内容提取到一个 NSData 中:

else if(type[0] == @encode(char *)[0])
{
char *str;
[inv getArgument: &str atIndex: i];
arg = [NSData dataWithBytes: str length: strlen(str)];
}

最后,对于后备方案(fallback case),直接将参数抽取到 NSData 对象中。NSGetSizeAndAlignment 函数能告知我们参数的尺寸大小,因此也能确定 NSData 需要的容量:

else
{
NSUInteger size;
NSGetSizeAndAlignment(type, &size, NULL);
arg = [NSMutableData dataWithLength: size];
[inv getArgument: [arg mutableBytes] atIndex: i];
}

现在,这个参数存储在 arg 中。在将其添加到数组之前,由于 NSArray 不接受 nil(空值),因此需要检查这一点,并改用 NSNull(空对象)来代替:

if(!arg)
arg = [NSNull null];

现在,将参数添加到数组中,并继续循环直到所有参数都被处理。

[args addObject: arg];
}

接下来,我们检查内存中是否存在这些参数。如果存在,我们就提取返回值并将其放入 NSInvocation 中;如果不存在,我们就调用原始代码块(block),然后从 NSInvocation 中提取返回值。在两种情况下,我们都需要知道方法的返回类型,并且需要判断它是对象、C 字符串还是其他类型,因此我们预先计算了这些信息:

const char *type = [sig methodReturnType];
BOOL isObj = type[0] == @encode(id)[0];
BOOL isCStr = type[0] == @encode(char *)[0];

接下来,检查内存。此操作通过 @synchronized 块实现,以确保代码的线程安全性:

id result;
@synchronized(memory)
{
result = [[[memory objectForKey: args] retain] autorelease];
}

如果参数(arguments)存在于字典(dictionary)中,那么 result 将包含返回值(return value)的对象表示(object representation)。否则,result 将为 nil。首先,让我们看看内存中没有条目的情况:

if(!result)
{

由于没有缓存的值,第一步就是调用原始 block 来计算返回值:

call();

一旦这个过程完成,返回值就会包含在 NSInvocation 中。我们需要将其获取到一个对象中,以便能够将其存储在内存里。如果返回值是一个对象,这其实非常简单。只需使用 getReturnValue: 方法就搞定了:

if(isObj)
{
[inv getReturnValue: &result];
}

若为 C 字符串,则需先获取字符串指针(string pointer),再将其转换为 NSData。请注意,与参数保存代码不同,这里我在字符串长度上加了 1,以便同时复制字符串的终止 NUL 字节(NUL byte)。由于该指针需要返回给调用方,且调用方会预期存在终止 NUL 字节,这种处理避免了额外的冗余转换。

else if(isCStr)
{
char *str;
[inv getReturnValue: &str];
result = str ? [NSData dataWithBytes: str length: strlen(str) + 1] : NULL;
}

最后,回退情况(fallback case)。像之前一样,我们将其获取到一个 NSData 中,使用 NSGetSizeAndAlignment 来计算所需的长度:

else
{
NSUInteger size;
NSGetSizeAndAlignment(type, &size, NULL);
result = [NSMutableData dataWithLength: size];
[inv getReturnValue: [result mutableBytes]];
}

再次,如果对象为 nil,则将其替换为 NSNull

if(!result)
result = [NSNull null];

现在有了结果,将其存入内存。至此,该特定代码路径已完成。合适的返回值已存放在 NSInvocation 中,将会返回给调用者:

@synchronized(memory)
{
[memory setObject: result forKey: args];
}
}

现在来看看结果在内存中被找到的情况。在这种情况下,我们只需要将result的内容填入NSInvocation中,使用setReturnValue:方法。在这里,我们需要对nil进行逆向转换 —— 检查result是否为NSNull,并将其转换为nil

else
{
if(result == [NSNull null])
result = nil;

如果返回值是一个对象,直接调用 setReturnValue: 即可完成:

if(isObj)
{
[inv setReturnValue: &result];
}

如果是一个 C 字符串,我们需要将字符串指针获取到局部变量中,然后设置它:

else if(isCStr)
{
const char *str = [result bytes];
[inv setReturnValue: &str];
}

否则,result 就是一个包含返回值的适当长度的 NSData。我们可以直接将指针传递给 invocation(调用),就这么简单!

else
{
[inv setReturnValue: [result mutableBytes]];
}
}
}, block);
}

对于那些已经忘记上下文的读者,代码块末尾的这个就是最初传入 MAMemoize 的原始 block(代码块),它告诉 MABlockForwarding 应该包装哪个 block。

示例 作为一个记忆化(memoization)的示例,我们来看一个简单的、计算开销大的 block:

__block uint64_t (^fib)(int) = ^uint64_t (int n) {
if(n <= 1)
return 1;
else
return fib(n - 1) + fib(n - 2);
}

这个计算斐波那契数的 block(代码块)通过递归实现众所周知的斐波那契函数。这种特定方法极其低效。else 子句中的两个递归调用会导致计算量呈指数级增长。其中每一个递归调用会再产生两个递归调用,而这两个调用又分别产生两个递归调用,如此循环。

然而,几乎所有的调用都是冗余的。如果分析一下就会发现,fib(5) 会调用 fib(4)fib(3),但随后 fib(4) 又会再次调用 fib(3)。对 fib 进行记忆化(Memoizing)处理将大幅提高其效率,因为后续对 fib(3) 的调用会被缓存,而不是重新计算。当然,计算斐波那契数本身存在许多更优的方法,但这仍是一个有趣示例。

记忆化处理 fib

要对 fib 进行记忆化,我们只需调用 MAMemoize 并将结果重新赋值给 fib

fib = MAMemoize(fib);

由于 fib 被声明为 __block,递归调用时会获取到记忆化(memoization)的版本,对于较大的数值,结果比原始版本快得多。

结论 这个记忆化包装器并非完全实用。一方面,它基于我那个庞大的 block 代理 hack,这在你打算发布的代码中真的无法使用。即使忽略这点,所有对 NSInvocation 的操作都很慢,这对于本质上属于优化的代码来说是个大问题。

尽管如此,这仍是一次极好的学习体验。即使不实用,这个记忆化包装器也展示了如何在复杂情况下处理 NSInvocation,以及如何处理你在实际编程中可能遇到的各种参数和返回值类型。除此之外,这类黑客技术本身就充满乐趣。

今天就到这里吧。请继续提交你们的建议。周五 Q & A(通常)由读者提交的主题驱动,所以如果你有任何想看到的主题,请发送过来!


#Original (English)

Source: https://www.mikeash.com/pyblog/friday-qa-2011-11-11-building-a-memoizing-block-proxy.html

Last time, I talked about my crazy hack that misuses the Objective-C message forwarding machinery to do block proxying. This allows writing code that interposes in front of an arbitrary block to intercept its arguments, manipulate its return value, etc. Today, I want to present an exanmple of using this hack which almost verges on the practical. Specifically, I’m going to discuss how to use it to build a generalized block memoization facility.

What the Heck is Memoization?In short, memoization is just a cache in front of a function. Imagine a pure function, which is to say, no side effects, and you always get the same result for the same arguments. Now imagine that this function is expensive to compute, and called multiple times with the same arguments. If the result is always the same with the same arguments, it’s a waste to constantly recompute it.

The standard answer to this is a cache. Whenever the function computes a result, it can put that result into a dictionary that’s keyed off the arguments. Whenever the function is called, it can check the dictionary first to see if the result is already in there. If it is, it can return the previously computed value.

Memoization is the process of performing this caching in a wrapper function. Instead of putting cache code into every function that fits this profile, you write the cache code once, as a wrapper around arbitrary functions. The wrapper has no idea how the function works, but it knows enough to cache return values and bypass the function if a particular set of arguments has already been computed.

CodeAs before, the code is available on GitHub here:

https://github.com/mikeash/MABlockForwarding

ApproachThe previous adventures eventually resulted in a MAForwardingBlock which takes a block and an interposer, and returns a new block. When the new block is called, the interposer is first called with an NSInvocation representing the call. The interposer can then manipulate the NSInvocation at will, optionally call through to the original block, and then give the NSInvocation’s return value back to the caller.

The goal here is to write an MAMemoize function. This function takes a block, and returns a new block which wraps the original. The new block memoizes the original, caching values based on the arguments passed in. MAMemoize will use MAForwardingBlock to interpose in front of the original block, and use an interposer block to perform the memoization.

In order to accomplish this, the interposer needs to unpack all of the arguments of the NSInvocation into an object which can be used as a dictionary key. It also needs to be able to translate the return value to and from an object that can be used as a dictionary value.

Once it’s able to do this, the rest is simple. Keep a dictionary which contains the memory. Unpack the arguments, and check to see if the dictionary has an entry for them. If it does, grab the value for those arguments and return it. Otherwise, call through to the original block, then fetch the return value and stash it in the memory dictionary before returning.

ImplementationTo start out with, we’ll define the MAMemoize function. It takes and return id because it deals with arbitrary blocks, not just a particular type:

id MAMemoize(id block) {

Next up, create the memory dictionary. This is just a local variable, but it will be captured by the interposer block, so it ends up persisting. Blocks can be interesting like that. You can think of a block as being an object with just one method, where the captured variables are instance variables.

NSMutableDictionary *memory = [NSMutableDictionary dictionary];

Now we call MAForwardingBlock and give it our interposer. The interposer block takes an NSInvocation and a block that calls through to the original block. Here’s the beginning of the call:

return MAForwardingBlock(^(NSInvocation *inv, void (^call)(void)) {

All of the code starting from here runs when the returned block is called. The inv argument contains the NSInvocation of that call, and the call block calls the original function.

The first thing to do is fetch the arguments into an object that can be used as a dictionary key. In this case, I chose to use an NSArray. Although this is probably not a very efficient dictionary key, this code isn’t very efficient anyway and it does the job fine. The array will be filled out with objects representing each argument.

Fetching the arguments also requires knowing the invocation’s method signature, so we grab that at the same time:

NSMethodSignature *sig = [inv methodSignature];
NSMutableArray *args = [NSMutableArray array];

Now, loop through the arguments and add them to the array. Note that the loop starts at argument 1, because argument 0 is the implicit block pointer argument and doesn’t need to be included in the cache:

for(unsigned i = 1; i < [sig numberOfArguments]; i++)
{

For the case where the argument is an object, the task is easy: fetch it using getArgument:atIndex: and then stash it in the array. For non-object arguments, life gets more complicated. In fact, it’s not possible to handle every possible type, since things like pointer types simply can’t be introspected well enough to understand how they’re being used.

My approach is to special-case C strings, since they seem like a common parameter type. For all other parameter types, fetch the raw argument into an NSData and treat that as the argument, not even making an attempt to interpret the meaning. In practice, this means that all primitive values will work, as will some structs and the rare pointers, like SEL which can be compared using == and persist for the lifetime of the app.

To start with, I create an arg local variable which will hold the extracted object, whatever it is. I also grab the string describing the type of this argument:

id arg = nil;
const char *type = [sig getArgumentTypeAtIndex: i];

The first thing is to check to see if this argument is an object. We can do this by comparing the first character of ‘type’ with the first character of ‘@encode(id)’:

if(type[0] == @encode(id)[0])
{

For this case, just grab the argument into arg. I add a small additional flourish where, if the argument conforms to NSCopying, it’s copied before being placed into the array:

[inv getArgument: &arg atIndex: i];
if([arg conformsToProtocol: @protocol(NSCopying)])
arg = [arg copy];
}

Next, check for a C string (any char * parameter is assumed to be a C string) using the same technique. If it is one, pull its contents into an NSData:

else if(type[0] == @encode(char *)[0])
{
char *str;
[inv getArgument: &str atIndex: i];
arg = [NSData dataWithBytes: str length: strlen(str)];
}

Finally, for the fallback case, pull the argument directly into an NSData. The NSGetSizeAndAlignment function can tell us how large the argument is and thus how large the NSData needs to be:

else
{
NSUInteger size;
NSGetSizeAndAlignment(type, &size, NULL);
arg = [NSMutableData dataWithLength: size];
[inv getArgument: [arg mutableBytes] atIndex: i];
}

Now we have this argument in arg. Before adding it to the array, since NSArray doesn’t like nil, check for that and use NSNull instead:

if(!arg)
arg = [NSNull null];

Now add the argument to the array and continue looping until all arguments are handled:

[args addObject: arg];
}

Next, we check memory to see if the arguments are in it. If they are, we extract the return value and put it into the NSInvocation. If they aren’t, we call the original block, then extract the return value from the `NSInvocation. In both cases, we need the method’s return type, and we need to know if it’s an object, a C string, or something else, so we precompute those:

const char *type = [sig methodReturnType];
BOOL isObj = type[0] == @encode(id)[0];
BOOL isCStr = type[0] == @encode(char *)[0];

Next up, check memory. This is done in a @synchronized block so that this code is thread safe:

id result;
@synchronized(memory)
{
result = [[[memory objectForKey: args] retain] autorelease];
}

If the arguments exist in the dictionary, then result will contain the object representation of the return value. Otherwise, result will be nil. First, let’s look at the case where there is no entry in memory:

if(!result)
{

Since there’s no saved value, the first thing to do is call the original block to compute the return value:

call();

Once this completes, the return value will be contained within the NSInvocation. We need to fetch it into an object so it can be stored in memory. If the return value is an object, this is really easy. Just use getReturnValue: and done:

if(isObj)
{
[inv getReturnValue: &result];
}

If it’s a C string, then we need to fetch the string pointer, then convert that to an NSData. Note that unlike the argument saving code, I add 1 to the length of the string in order to copy the string’s terminating NUL byte as well. Since this pointer needs to be returned to the caller, and the caller will expect the terminating NUL, this saved additional redundant conversion:

else if(isCStr)
{
char *str;
[inv getReturnValue: &str];
result = str ? [NSData dataWithBytes: str length: strlen(str) + 1] : NULL;
}

Finally, the fallback case. Like before, we fetch it into an NSData, using NSGetSizeAndAlignment to figure out the necessary length:

else
{
NSUInteger size;
NSGetSizeAndAlignment(type, &size, NULL);
result = [NSMutableData dataWithLength: size];
[inv getReturnValue: [result mutableBytes]];
}

And again, if the object is nil, replace it with NSNull:

if(!result)
result = [NSNull null];

Now that we have the result, store it in memory. At this point, we’re done with this particular code path. The proper return value is already in the NSInvocation and will be returned to the caller:

@synchronized(memory)
{
[memory setObject: result forKey: args];
}
}

Now let’s look at the case where a result was found in memory. In that case, all we have to do is stuff the contents of result into the NSInvocation using setReturnValue:. Here, we do the reverse transformation for nil, checking result for NSNull and changing it to nil:

else
{
if(result == [NSNull null])
result = nil;

If the return type is an object, a direct call to setReturnValue: gets the job done:

if(isObj)
{
[inv setReturnValue: &result];
}

If it’s a C string, we have to fetch the string pointer into a local variable, then set that:

else if(isCStr)
{
const char *str = [result bytes];
[inv setReturnValue: &str];
}

Otherwise, result is an NSData of the appropriate length containing the return value. We can just pass the pointer straight into the invocation, and that’s all there is to it!

else
{
[inv setReturnValue: [result mutableBytes]];
}
}
}, block);
}

For those of you who have lost track, the block at the end of this is the original block which was passed into MAMemoize, and tells MABlockForwarding which block to wrap.

ExampleAs an example of memoization, let’s look at a simple, computationally-expensive block:

__block uint64_t (^fib)(int) = ^uint64_t (int n) {
if(n <= 1)
return 1;
else
return fib(n - 1) + fib(n - 2);
}

This fib block computes the well known Fibonacci function using recursion. This particular approach is extremely slow. The two recursive calls in the else clause result in an exponential growth of computation. Each of the two recursive calls spawns two more recursive calls, which in turn each spawn two more recursive calls, etc.

However, almost all of the calls are redundant. If you analyze it, you’ll see that fib(5) calls fib(4) and fib(3), but then fib(4) just calls fib(3) again. Memoizing fib will make it much faster, since subsequent calls to fib(3) will be cached rather than recomputed. There are, of course, much better ways compute the Fibonacci function altogether, but this still serves as an interesting example.

To memoize fib, we just call MAMemoize and assign the result back to fib:

fib = MAMemoize(fib);

Since fib is declared as __block, the recursive calls pick up the memoized version, and the result is massively faster on large values than the original.

ConclusionThis memoization wrapper is not entirely useful. For one thing, it’s based on my colossal block proxying hack, which really can’t be used in code you intend to ship. Even ignoring that, all of the games played with NSInvocation are slow and this is a big problem for code that is, essentially, an optimization.

Despite this, it’s a great learning experience. Even if it’s not practical, this memoization wrapper illustrates how to deal with NSInvocation in complicated situations and deal with some of the variety of argument and return types that you’re likely to encounter in the wild. Beyond that, this kind of hacking is just good fun.

That wraps things up for today. Keep those suggestions coming. Friday Q&A is (usually) driven by reader-submitted topics, so if you have a subject you’d like to see covered, send it in!