Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ObjC Block Support #261

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 254 additions & 0 deletions objc/objc_block_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2024 The Ebitengine Authors

package objc

import (
"fmt"
"reflect"
"sync"
"unsafe"

"github.com/ebitengine/purego"
)

const (
// end-goal of these defaults is to get an Objectve-C memory-managed block object,
// that won't try to free() a Go pointer, but will call our custom blockFunctionCache.Delete()
// when the reference count drops to zero, so the associated function is also unreferenced.

// blockBaseClass is the name of the class that block objects will be initialized with.
blockBaseClass = "__NSMallocBlock__"
// blockFlags is the set of flags that block objects will be initialized with.
blockFlags = blockHasCopyDispose | blockHasSignature

// blockHasCopyDispose is a flag that tells the Objective-C runtime the block exports Copy and/or Dispose helpers.
blockHasCopyDispose = 1 << 25
// blockHasSignature is a flag that tells the Objective-C runtime the block exports a function signature.
blockHasSignature = 1 << 30
)

// blockDescriptor is the Go representation of an Objective-C block descriptor.
// It is a component to be referenced by blockDescriptor.
type blockDescriptor struct {
_ uintptr
Size uintptr
_ uintptr
Dispose uintptr
Signature *uint8
}

// blockLayout is the Go representation of the structure abstracted by a block pointer.
// From the Objective-C point of view, a pointer to this struct is equivalent to an ID that
// references a block.
type blockLayout struct {
Isa Class
Flags uint32
_ uint32
Invoke uintptr
Descriptor *blockDescriptor
}

/*
blockCache is a thread safe cache of block layouts.

The function closures themselves are kept alive by caching them internally until the Objective-C runtime indicates that
they can be released (presumably when the reference count reaches zero.) This approach is used instead of appending the function
object to the block allocation, where it is out of the visible domain of Go's GC.
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use // style comments for consistency.

type blockFunctionCache struct {
mutex sync.RWMutex
functions map[Block]reflect.Value
}

// Load retrieves a function (in the form of a reflect.Value, so Call can be invoked) associated with the key Block.
func (b *blockFunctionCache) Load(key Block) reflect.Value {
b.mutex.RLock()
defer b.mutex.RUnlock()
return b.functions[key]
}

// Store associates a function (in the form of a reflect.Value) with the key Block.
func (b *blockFunctionCache) Store(key Block, value reflect.Value) Block {
b.mutex.Lock()
defer b.mutex.Unlock()
b.functions[key] = value
return key
}

// Delete removed the function associated with the key Block.
func (b *blockFunctionCache) Delete(key Block) {
b.mutex.Lock()
defer b.mutex.Unlock()
delete(b.functions, key)
}

// newBlockFunctionCache initilizes a new blockFunctionCache
func newBlockFunctionCache() *blockFunctionCache {
return &blockFunctionCache{functions: map[Block]reflect.Value{}}
}

// blockCache is a thread safe cache of block layouts.
//
// It takes advantage of the block being the first argument of a block call being the block closure,
// only invoking purego.NewCallback() when it encounters a new function type (rather than on for every block creation.)
// This should mitigate block creations putting pressure on the callback limit.
type blockCache struct {
sync.Mutex
descriptorTemplate blockDescriptor
layoutTemplate blockLayout
layouts map[reflect.Type]blockLayout
Functions *blockFunctionCache
}

// encode returns a blocks type as if it was given to @encode(typ)
func (*blockCache) encode(typ reflect.Type) *uint8 {
// this algorithm was copied from encodeFunc,
// but altered to panic on error, and to only accep a block-type signature.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just do this check first and then call the encodeFunc instead of copying the code.

if type.Kind() == reflect.Func && ((typ.NumIn() == 0) || (typ.In(0) != reflect.TypeOf(Block(0)))) {
		panic(fmt.Sprintf("objc: A Block implementation must take a Block as its first argument; got %v", typ.String()))
	}
encoding, err := encodeFunc(fn)
if err != nil {
   panic(fmt.Sprintf("objc: failed to encode Block %s", err))
}
``

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is, encodeFunc (as it stands) has a check that enforces an Objective-C method signature, which is not the same as a block signature. Maybe worth splitting up that functon from it's check first, and having both implementations share?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you think you can find a good solution go for it otherwise it might be ok to just leave it how you have it now

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do. Thank you.

if (typ == nil) || (typ.Kind() != reflect.Func) {
panic("objc: not a function")
}

var encoding string
switch typ.NumOut() {
case 0:
encoding = encVoid
default:
returnType, err := encodeType(typ.Out(0), false)
if err != nil {
panic(fmt.Sprintf("objc: %v", err))
}
encoding = returnType
}

if (typ.NumIn() == 0) || (typ.In(0) != reflect.TypeOf(Block(0))) {
panic(fmt.Sprintf("objc: A Block implementation must take a Block as its first argument; got %v", typ.String()))
}

encoding += encId
for i := 1; i < typ.NumIn(); i++ {
argType, err := encodeType(typ.In(i), false)
if err != nil {
panic(fmt.Sprintf("objc: %v", err))
}
encoding = fmt.Sprint(encoding, argType)
}

// return the encoding as a C-style string.
return &append([]uint8(encoding), 0)[0]
}

// GetLayout retrieves a blockLayout VALUE constructed with the supplied function type
// It will panic if the type is not a valid block function.
func (b *blockCache) GetLayout(typ reflect.Type) blockLayout {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be exported as it is a method on an unexpected type

b.Lock()
defer b.Unlock()

// return the cached layout, if it exists.
if layout, ok := b.layouts[typ]; ok {
return layout
}

// otherwise: create a layout, and populate it with the default templates
layout := b.layoutTemplate
layout.Descriptor = &blockDescriptor{}
(*layout.Descriptor) = b.descriptorTemplate
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: these parenthesis aren't necessary


// getting the signature now will panic on invalid types before we invest in creating a callback.
layout.Descriptor.Signature = b.encode(typ)

// create a global callback.
// this single callback can dispatch to any function with the same signature,
// since the user-provided functions are associated with the actual block allocations.
layout.Invoke = purego.NewCallback(
reflect.MakeFunc(
typ,
func(args []reflect.Value) (results []reflect.Value) {
return b.Functions.Load(args[0].Interface().(Block)).Call(args)
},
).Interface(),
)

// store it and return it
b.layouts[typ] = layout
return layout
}

// newBlockCache initilizes a block cache.
// It should not be called until AFTER libobjc is fully initialized.
func newBlockCache() *blockCache {
cache := &blockCache{
descriptorTemplate: blockDescriptor{
Size: unsafe.Sizeof(blockLayout{}),
},
layoutTemplate: blockLayout{
Isa: GetClass(blockBaseClass),
Flags: blockFlags,
},
layouts: map[reflect.Type]blockLayout{},
Functions: newBlockFunctionCache(),
}
cache.descriptorTemplate.Dispose = purego.NewCallback(cache.Functions.Delete)
return cache
}

// blocks is the global block cache
var blocks *blockCache

// Block is an opaque pointer to an Objective-C object containing a function with its associated closure.
type Block ID
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for keeping this as an opaque pointer instead of just *blockLayout?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A "Block" in Objective-C is first-class object (e.g. can be cast to an ID and sent messages like any other object.) The problem is, ID in purego's objc implementation has a base of uintptr, so defining a block as a pointer type would complicate down-casting. I believe the choice to define ID as uintptr instead of *struct{...} (which would better align to what it actually is in Objective-C) was to allow receiver methods to be defined (since go does not allow receiver methods on pointer type aliases), but that's just speculation/intuition.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are people going to actually cast Block to ID? Block doesn't have any methods does it?

Of course if a class extended Block then you'd probably want ID but then are you using it as a Block still?

Perhaps a Block.ID() method and BlockFromID() function that does the unsafe cast? Perhaps that can be added later as I'm not confident people need to use a Block as an ID

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does have methods (most importantly Copy() and Release() for lifetime management), but taking your thoughts into account, this could be refactored to behave like Protocol, which is used as a struct pointer, even though it, too, is actually a first-class object?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean there are no objc methods that users are trying to call.

this could be refactored to behave like Protocol

Yes that's what I'm thinking


// Copy creates a copy of a block on the Objective-C heap (or increments the reference count if already on the heap.)
// Use Block.Release() to free the copy when it is no longer in use.
func (b Block) Copy() Block {
return _Block_copy(b)
}

// GetImplementation populates a function pointer with the implementation of a Block.
// Function will panic if the Block is not kept alive while it is in use
// (possibly by using Block.Copy()).
func (b Block) GetImplementation(fptr any) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why should this be exported?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't actually happy with it either. It was meant to provide provide synergy with purego's RegisterFunc in the block case, but has potential for misuse because of blocks having managed lifetimes.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone wants to call a Block directly can't they just call RegisterFunc? It's just a C function with a objc.Block as the first argument current?

Copy link
Author

@K1000000 K1000000 Jul 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. Perhaps then, just replace this method with Implementation() (to treat it like a read-only property) that returns a uintptr, and allow the user to call RegisterFunc() at their own discretion (keeping in mind, the lifetime caveats, of course?), with the option to use just use an Invoke() or BlockInvoke[]() that have already been implemented with lifetime-safety in mind?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have a use case where grabbing the raw uintptr to call directly is helpful? I want to limit the exposed API to only the necessary parts

// there is a runtime function imp_implementationWithBlock that could have been used instead,
// but experimentation has shown the returned implementation doesn't actually work as expected.
// also, it creates a new copy of the block which must be freed independently,
// which would have made this implementation more complicated than necessary.
// we know a block ID is actually a pointer to a blockLayout struct, so we'll take advantage of that.
if b != 0 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to write this as:

if b == 0 {
    return
}
if cfn := (*(**blockLayout)(unsafe.Pointer(&b))).Invoke; cfn == 0 {
    return
}
purego.RegisterFunc(fptr, cfn)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WIll fix. Note that the cfn := clause would need to be pulled out of the if block to actually work, but simple enough.

if cfn := (*(**blockLayout)(unsafe.Pointer(&b))).Invoke; cfn != 0 {
purego.RegisterFunc(fptr, cfn)
}
}
}

// Invoke is calls the implementation of a block.
func (b Block) Invoke(args ...any) {
InvokeBlock[struct{}](b, args...)
}

// Release decrements the Block's reference count, and if it is the last reference, frees it.
func (b Block) Release() {
_Block_release(b)
}

// NewBlock takes a Go function that takes a Block as its first argument.
// It returns an Block that can be called by Objective-C code.
// The function panics if an error occurs.
// Use Block.Release() to free this block when it is no longer in use.
func NewBlock(fn interface{}) Block {
// get or create a block layout for the callback.
layout := blocks.GetLayout(reflect.TypeOf(fn))
// we created the layout in Go memory, so we'll copy it to a newly-created Objectve-C object.
block := Block(unsafe.Pointer(&layout)).Copy()
// associate the fn with the block we created before returning it.
return blocks.Functions.Store(block, reflect.ValueOf(fn))
}

// InvokeBlock is a convenience method for calling the implementation of a block.
func InvokeBlock[T any](block Block, args ...any) T {
block = block.Copy()
defer block.Release()

var invoke func(Block, ...any) T
block.GetImplementation(&invoke)
return invoke(block, args...)
}
Loading
Loading