3

I have a Swift object that takes a dictionary of blocks (keyed by Strings), stores it and runs block under given key later at some point depending on external circumstances (think different behaviours depending on the backend response):

@objc func register(behaviors: [String: @convention(block) () -> Void] {
  // ...
}

It's used in a mixed-language project, so it needs to be accessible from both Swift and Objective-C. That's why there's @convention(block), otherwise compiler would complain about not being able to represent this function in Objective-C.

It works fine in Swift. But when I try to invoke it from Objective-C like that:

[behaviorManager register:@{
  @"default": ^{
    // ...
  }
}];

The code crashes and I get following error:

Could not cast value of type '__NSGlobalBlock__' (0x...) to '@convention(block) () -> ()' (0x...).

Why is that, what's going on? I thought @convention(block) is to specifically tell the compiler that Objective C blocks are going to be passed, and that's exactly what gets passed to the function in the call.

The Dreams Wind
  • 8,416
  • 2
  • 19
  • 49
raven_raven
  • 343
  • 2
  • 6
  • 17
  • 2
    You shouldn't have to do this, but you might try sending a `copy` message to the block: `[^{ ...} copy]`. – Cristik Nov 04 '22 at 16:07
  • 1
    I don't have the answer due the lack of information on the whole classes. But I'll hint you to read the implementation about the NSGlobalBlock. In some circumstances, it get deallocated because no strong pointer to it, and ARC take it down. Source: https://clang.llvm.org/docs/Block-ABI-Apple.html I also saw something about copying the block. – Allan Garcia Nov 04 '22 at 17:06
  • 2
    I can reproduce the problem, it looks like a bug to me. Copying the block does not help in my test. – Martin R Nov 04 '22 at 17:19
  • @AllanGarcia thank you for responding and tips. You can reproduce the problem with just an empty function with a header exactly like mine - crash happens on the line where it's called. I created an empty project with just this and still experience this crash. Unfortunately retaining doesn't help, it doesn't seem like the block gets deallocated. To be absolutely sure I retained the array in a strong property and still see the same. – raven_raven Nov 04 '22 at 17:44
  • @Cristik thank you for a suggestion, unfortunately the problem persists, `copy` message sent to the block doesn't change much. – raven_raven Nov 04 '22 at 17:46
  • Another tip I just got suggested by Youtube. https://www.youtube.com/watch?v=iJPK3HCxk7E – Allan Garcia Nov 04 '22 at 18:26
  • 1
    Some older threads about problems with passing blocks between Swift and Objective-C: https://stackoverflow.com/q/24586293/1187415, https://stackoverflow.com/q/24595692/1187415, https://stackoverflow.com/q/46224806/1187415. It seems that one has to pass the block as an AnyObject, and then “unsafeBitCast” it back to a Swift block. – Martin R Nov 04 '22 at 21:15
  • 1
    Or wrap the block in a custom class, as here: https://stackoverflow.com/a/24760061/1187415. – Martin R Nov 04 '22 at 21:39
  • @MartinR thank you, I was thinking in going around it in a similar manner, thinking that wrapping blocks around in object would help. Will require some changes to existing code, but it's all right. I just wanted to know if I don't understand something, if I'm doing something wrong, or if it's a problem of a compiler/language. – raven_raven Nov 05 '22 at 09:17

1 Answers1

1

That's why there's @convention(block), otherwise compiler would complain about not being able to represent this function in Objective-C

For the sake of consistency: commonly you use @convention attribute the other way around - when there is an interface which takes a C-pointer (and implemented in C) or an Objective-C block (and implemented in Objective-C), and you pass a Swift closure with a corresponding @convention as an argument instead (so the compiler actually can generate appropriate memory layout out of the Swift closure for the C/Objective-C implementation). So it should work perfectly fine if it's Objective-C side where the Swift-created closures are called like blocks:

@interface TDWObject : NSObject

- (void)passArguments:(NSDictionary<NSString *, void(^)()> *)params;

@end

If the class is exposed to Swift the compiler then generates corresponding signature that takes a dictionary of @convention(block) values:

func passArguments(_ params: [String : @convention(block) () -> Void])

This, however, doesn't cancel the fact that closures with @convention attribute should still work in Swift, but the things get complicated when it comes to collections, and I assume it has something with value-type vs reference-type optimisation of Swift collections. To get it round, I'd propose to make it apparent that this collection holds a reference type, by promoting it to the [String: AnyObject] and casting later on to a corresponding block type:

@objc func takeClosures(_ closures: [String: AnyObject]) {
    guard let block = closures["One"] else {
        return // the block is missing
    }
    let closure = unsafeBitCast(block, to: ObjCBlock.self)
    closure()
}

Alternatively, you may want to wrap your blocks inside of an Objective-C object, so Swift is well aware of that it's a reference type:

typedef void(^Block)();

@interface TDWBlockWrapper: NSObject

@property(nonatomic, readonly) Block block;

@end

@interface TDWBlockWrapper ()

- (instancetype)initWithBlock:(Block)block;

@end

@implementation TDWBlockWrapper

- (instancetype)initWithBlock:(Block)block {
    if (self = [super init]) {
        _block = block;
    }
    return self;
}

@end

Then for Swift it will work as simple as that:

@objc func takeBlockWrappers(_ wrappers: [String: TDWBlockWrapper]) {
    guard let wrapper = wrappers["One"] else {
        return // the block is missing
    }
    wrapper.block()
}
The Dreams Wind
  • 8,416
  • 2
  • 19
  • 49
  • 1
    Hi. The suggestion to change the type of collection to "[String: AnyObject]", and then cast it by calling "let objCBlock = unsafeBitCast(block, to: (@convention(block) () -> Void).self)" seems to do the trick. I even tried unsafeBitCast before asking this question, but I must've been doing something wrong. Thank you! It doesn't solve underlying issue per se, but that's impossible given that it looks like a compiler bug. I filed a radar to Apple, and in the meantime I'll accept this answer as it lays out possible workarounds. Thanks again. – raven_raven Nov 06 '22 at 13:41
  • One final piece of advice I have for this situation: when calling this function from Swift, it any blocks in the collection need to be casted "as @convention(block) () -> Void as AnyObject". – raven_raven Nov 06 '22 at 13:49