Skip to content

Add support for Objective-C class implementation #5064

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

Merged
merged 27 commits into from
May 8, 2025

Conversation

harold-b
Copy link
Contributor

@harold-b harold-b commented Apr 21, 2025

Add support for Objective-C class implementation

The purpose of this work is to add full Odin-native support for creating Objective-C classes, not just consuming them.
Doing so without modifying syntax, only leveraging attributes and new compiler intrinsics. I also attempt to leverage existing attributes and augment them when necessarry to add the desired new functionality.

This facilitates creating new classes where needed, for things like delegates, event handlers, etc. without ugly runtime workarounds, or leaving Odin to provide it in a different language.

This PR has the following requirements:

  • Automatically register user-made classes with the Objective-C runtime at startup
  • Allow specifying a superclass
  • Allow association with a single type that would serve as a singular Ivar that is added to the new Objective-C class
  • Allow specifying a selector name manually to be used with the implemented class
  • Provide an easy way to get the ivar value for an Objective-C class.

What it does not aim to do (at least not this PR):

  • Add new syntax
  • Register associated protocols with a class.
  • Force protocol conformance or register protocols during class generation. Supports for things like conformsToProtocol won't be available.
  • Attempt to automatically propagate the Odin state (context) into the Objective-C runtime flow. I found a clean way to do this in the same vein as the deferred_in/out attributes. See @objc_context_provider

Requirements

  • Implement @objc_implement attribute for structs

  • Implement @objc_superclass attribute for procs

    • Disallow superclass cycles
    • Ensure sane ordering during generation so that superclasses get generated first
  • Implement @objc_ivar attribute

  • Limit generating the global ivar offset to intrinsic usage?

  • Implement @objc_selector attribute

  • Implement @objc_context_provider which specifies a procedure that provider a context for methods with the Odin calling convention

  • Add pseudo-field resolution for Ivar types associated with an Objective-C class

  • Apply a legitimate strategy for export/linkage

    • If the @export attribute is applied to an Objective-C class, then all methods are exported. Currently all are exported unconditionally (@export is implied)
    • All classes with @(objc_implement) should have their class registered and their methods emitted. Because of the dynamic nature of the Objective-C runtime, this makes the most sense. Especially after testing for a week, there are occasions that a class is expected to be present but it is not because it was not referenced through msgSend in an alloc call or similar with an intrinsic that takes an objc_Class.
  • Checker (See comment below):

    • Basic preliminary checks
    • Proper checks specification
    • Proper check specification applied
  • Proper, understandable error messages

  • Resolve all pending TODOs

    • Required or high-importance TODOs
    • Low importance or optional TODOs

State and discussion

At the time of this writing the full example below works as all the main requirements have been implemented. It will only work with a single module as there's still issues with the changes. Due to lack of experience with the codebase there needs to be some guidance from the team to get this to a proper shippable state.

I'd like to get feedback on attribute and intrinsic naming in addition to the code changes itself.

Practical Explanation & Usage

Creating a new Objective-C class looks like it does when consuming an existing one, except we add the @objc_implement attribute:

@(objc_class="HBAppDelegate", objc_implement)
HBAppDelegate :: struct {
    using _: NSF.Object,
    using _: NS.ApplicationDelegate,
}

Typically you also want to specify a super/base class as well:

@(objc_class="HBAppDelegate", objc_implement, objc_superclass=NS.Object)
HBAppDelegate :: struct { ...

Finally you may want to associate a type to serve as the class' Ivar:

@(objc_class="HBAppDelegate", objc_implement, objc_superclass=NS.Object, objc_ivar=HBAppDelegateT)
HBAppDelegate :: struct {
    using _: NSF.Object,
    using _: NS.ApplicationDelegate,
}

// This serves as the only Ivar for HBAppDelegate, it's just a regular struct. 
HBAppDelegateT :: struct {
    ctx: runtime.Context,
    // ...
}

You can add methods to it the same way you do when consuming existing Objective-C methods, simply add the same @objc_implement attribute:

@(objc_type=HBAppDelegate, objc_implement, objc_name="alloc", objc_is_class_method=true)
HBAppDelegate_alloc :: proc "c" () -> ^HBAppDelegate {
    return msgSend(^HBAppDelegate, HBAppDelegate, "alloc")
}

The compiler will generate a hidden function that conforms to the signature that the Objective-C runtime expects, which simply forwards the call to our method proc:

__$objc_method::MyFoo::alloc :: proc "c" ( self: objc_id, _cmd: objc_SEL ) -> ^HBAppDelegate {
    return HBAppDelegate_alloc()
}

Any arguments will be forwarded:

@(objc_type=HBAppDelegate, objc_implement, objc_name="initWithContext")
HBAppDelegate_initWithContext :: proc "c" ( self: ^HBAppDelegate, ctx: runtime.Context ) -> ^HBAppDelegate {
    s := intrinsics.ivar_get(self, HBAppDelegateT)
    s.ctx = ctx

    return self
}

// Generates:
__$objc_method::MyFoo::alloc :: proc "c" ( self: objc_id, _cmd: objc_SEL, ctx: runtime.Context ) -> ^HBAppDelegate {
    return HBAppDelegate_initWithContext(self, ctx)
}

Notice the use of the new intrinsic intrinsics.ivar_get. This intrinsic will obtain a pointer to the Type's data of the Ivar from an instance, if one is associated with the class. This is implemented by recording the offset to the ivar at startup, then it simply offsets from the instance pointer during the call to the intrinsic.

You can also specify the selector name to be used for a method. By default the value of @objc_name will be used, if one is not specified. But there will be many cases when you may want to specify one manually, as it is when implementing a protocol. Specify it via the @objc_selector attribute:

@(objc_type=HBAppDelegate, objc_implement, objc_name="applicationWillFinishLaunching", objc_selector="applicationWillFinishLaunching:")
HBAppDelegate_applicationWillFinishLaunching :: proc "c" (self: ^HBAppDelegate, notification: ^NS.Notification) {
...
}

@(objc_type=HBAppDelegate, objc_implement, objc_name="applicationDidFinishLaunching", objc_selector="applicationDidFinishLaunching:")
HBAppDelegate_applicationDidFinishLaunching :: proc "c" (self: ^HBAppDelegate, notification: ^NS.Notification) {
...
}

Full working example of an NSAppDelegate (partially) implemented in Odin:

package objcsandbox

import "base:intrinsics"
import "base:runtime"
import "core:log"
import NSF "libs:darwodin/Foundation"
import NS  "libs:darwodin/AppKit"

msgSend  :: intrinsics.objc_send
ivar_get :: intrinsics.ivar_get


main :: proc() {
    context.logger = log.create_console_logger()

    app := NS.Application.sharedApplication()
    app->setActivationPolicy(.Regular)
    app->activate()
    app->setDelegate(HBAppDelegate.alloc()->initWithContext(context))
    app->run()
}

State :: enum {
    Initializing,
    Launching,
    FinishedLaunching,
}
HBAppDelegateT :: struct {
    ctx:   runtime.Context,
    state: State,
}

@(objc_class="HBAppDelegate", objc_superclass=NSF.Object, objc_implement, objc_ivar=HBAppDelegateT)
HBAppDelegate :: struct {
    using _: NSF.Object,
    using _: NS.ApplicationDelegate,
}

@private
get_self :: #force_inline proc "c" ( self: ^HBAppDelegate ) -> ^HBAppDelegateT {
    return ivar_get(self, HBAppDelegateT)
}

@(objc_type=HBAppDelegate, objc_implement, objc_name="alloc", objc_is_class_method=true)
HBAppDelegate_alloc :: proc "c" () -> ^HBAppDelegate {
    return msgSend(^HBAppDelegate, HBAppDelegate, "alloc")
}

@(objc_type=HBAppDelegate, objc_implement, objc_name="initWithContext")
HBAppDelegate_initWithContext :: proc "c" ( self: ^HBAppDelegate, ctx: runtime.Context ) -> ^HBAppDelegate {
    s := get_self(self)
    s.ctx = ctx

    return self
}

@(objc_type=HBAppDelegate, objc_implement, objc_name="applicationWillFinishLaunching", objc_selector="applicationWillFinishLaunching:")
HBAppDelegate_applicationWillFinishLaunching :: proc "c" (self: ^HBAppDelegate, notification: ^NSF.Notification) {
    s := get_self(self)
    context = s.ctx
    log.infof("applicationWillFinishLaunching: %v -> %v", s.state, State.Launching)
    s.state = .Launching
}

@(objc_type=HBAppDelegate, objc_implement, objc_name="applicationDidFinishLaunching", objc_selector="applicationDidFinishLaunching:")
HBAppDelegate_applicationDidFinishLaunching :: proc "c" (self: ^HBAppDelegate, notification: ^NSF.Notification) {
    s := get_self(self)
    context = s.ctx
    log.infof("applicationDidFinishLaunching: %v -> %v", s.state, State.FinishedLaunching)
    s.state = .FinishedLaunching
}

@harold-b harold-b changed the title Add initial support for Objective-C class implementation Add support for Objective-C class implementation Apr 21, 2025
@harold-b
Copy link
Contributor Author

After playing around a bit with the last state of things, using an intrinsic manually to access fields with the object's ivar was quite cumbersome.
Having to set the context manually each time also became cumbersome quickly.

I solved both issues by adding 2 new features to complete the desired functionality:

  • Pseudo field support for accessing the Ivar from Objective-C object instances, much like one does in native Objective-C.
  • Allow non-class methods to use the Odin calling convention by specifying a context provider procedure via an attribute on the declaration of the class struct.

The former works by simply emitting the objc_ivar_get intrinsic during Selector expression resolution for Objective-C structs that have an ivar set. Then replacing the original address with the adjusted one.

The second uses a new attribute @objc_context_provider which takes a procedure with a specific signature:

  • It must be "c" calling convention
  • It must have a single parameter, which is either a pointer to the Objective-C object, or its Ivar type.
  • It must return a single value, which is a context

During generation, when a method with Odin calling convention is encountered, the wrapper procedure is adjusted to first set a context by calling the context provider procedure with the 'self' argument. If the self argument is the Ivar type, it first emits an objc_ivar_get and passes that instead. The reason for allowing the Ivar type is so that a base struct with a context and any other related data may be used as an embedded field in differing, but related, ObjC Class' Ivar.

Example:

main :: proc() {

    // Set initial context
    context.user_index = 777
    foo := MyFoo.alloc()->initWithContext(context)

    // Ivar pseudo-field access
    name := foo.name
    fmt.printfln("I am '%v'", name)

    foo.name = "Not_Fooey"
    fmt.printfln("Now I am '%v'", foo.name)

    // Odin calling convention call
    foo->sayHello()
}

@private
get_ctx :: proc "c" ( self: ^MyFooT ) -> runtime.Context {
    return self.ctx
}

@(objc_class="MyFoo", objc_implement, objc_superclass=NS.Object, objc_ivar=MyFooT, objc_context_provider=get_ctx)
MyFoo  :: struct { using _: NS.Object }
MyFooT :: struct {
    ctx:  runtime.Context,
    name: string,
    age:  int,
}

@(objc_type=MyFoo, objc_name="alloc", objc_implement, objc_is_class_method=true)
MyFoo_alloc :: proc "c" () -> ^MyFoo {
    return msgSend(^MyFoo, MyFoo, "alloc")
}

@(objc_type=MyFoo, objc_implement, objc_name="initWithContext")
MyFoo_initWithContext :: proc "c" (self: ^MyFoo, ctx: runtime.Context) -> ^MyFoo {
    // Set initial context via ivar explicitly
    context = ctx
    vself: ^MyFooT = ivar_get(self, MyFooT)
    self.ctx  = ctx
    self.name = "Fooey"
    self.age  = 32
    fmt.printfln("Set name to %v", self.name)

    return self
}

@(objc_type=MyFoo, objc_implement, objc_name="sayHello")
MyFoo_sayHello :: proc (self: ^MyFoo) {
    fmt.printfln("Hello, I am %v :: %v", self.name, context.user_index)
}

@Lperlind
Copy link
Contributor

I am guessing this PR could completely replace the code written such as Application delegate that created a class during runtime

https://github.com/odin-lang/Odin/blob/master/core%2Fsys%2Fdarwin%2FFoundation%2FNSApplication.odin#L253

@harold-b
Copy link
Contributor Author

Indeed. And it facilitates being able to have more than 1 protocol implemented in a single class, as well as replacing base class functionality in the hierarchy. You could do the latter with darwodin but it's painful and bloated.

There are also cases where you need to provide a separate object with a specific selector, such as when using GestureRecognizers, and this would have meant generating it all and registering it at runtime manually.

@Lperlind
Copy link
Contributor

That will be super cool. I would mark the those procedures as deprecated and point people to this stuff instead


// TODO(harold): Is this the right way to do this??? The referenced entity must be already resolved
// so that we can access its objc_superclass attribute
check_single_global_entity(ctx->checker, super->Named.type_name, super->Named.type_name->decl_info);
Copy link
Member

Choose a reason for hiding this comment

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

Checking it again should not be a problem, but is this what you wanted since you reassigned super above. This seems a bit unsafe.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed—But is this the correct location for this check?

@harold-b
Copy link
Contributor Author

harold-b commented May 3, 2025

All checker spec implemented except for Class name must not already exist (Already handled for us?).

I don't know if this is already handled somewhere since that attribute predates this PR.

I will try to finish that off tomorrow.

What's left is unconditional exporting and TODOs cleanup/clarifications.

@harold-b
Copy link
Contributor Author

harold-b commented May 3, 2025

that all selector call expressions on an @objc_implement method should always resolve to Objective-C dispatch (msgSend), never direct dispatch.

I am leaving what I mentioned there as outside of the scope of this PR as I'd like to not keep increasing the scope to an already massive PR. Hopefully we can discuss more at length later and agree on what is to be done.

@harold-b
Copy link
Contributor Author

harold-b commented May 3, 2025

All checker spec implemented except for Class name must not already exist (Already handled for us?).

I don't know if this is already handled somewhere since that attribute predates this PR.

I will try to finish that off tomorrow.

What's left is unconditional exporting and TODOs cleanup/clarifications.

I've implemented both. There's now a class name re-use check for all classes using @objc_implement.

I've moved setting the implicit export and linkage to a better location outside of the attribute resolution stage, where it won't get overwritten.

All that's left is clearing up a couple of minor TODOs

@harold-b harold-b marked this pull request as ready for review May 3, 2025 07:23
@harold-b
Copy link
Contributor Author

harold-b commented May 3, 2025

This is now ready for review.

@gingerBill
Copy link
Member

I'm not a huge fan of the @(objc_context_provider) idea since it seems kind of unnecessary if the concept of an ivar already exists. It isn't clear when or where this context callback is being invoked, compared doing a manual context = self.ctx.

@harold-b
Copy link
Contributor Author

harold-b commented May 5, 2025

I'm not a huge fan of the @(objc_context_provider) idea since it seems kind of unnecessary if the concept of an ivar already exists. It isn't clear when or where this context callback is being invoked, compared doing a manual context = self.ctx.

That's unfortunate. It is certainly useful when filling out large protocols or implementing full classes completely in Odin.

I modeled it after the deferred_in/out mechanism——which one could argue is also unclear as to where they are invoked/how it works barring documentation. I understand there's distinction here as we're also talking about context propagation, and completely see where you guys' concern is coming from.

What do you think about having calls to ObjC methods be ObjC-runtime dispatch only as I specified here? Meaning, whenever these methods are invoked directly from the Odin side (which should be the rare case), they would unconditionally be dispatched via msgSend(...), that is, through ObjC runtime dispatch. This way the context is always coming from the @objc_context_provider proc.

@harold-b
Copy link
Contributor Author

harold-b commented May 5, 2025

Also note that @objc_context_provider is optional. If the user wants to use "c" calling convention and specify context explicitly, he still can.

@gingerBill
Copy link
Member

I'm merging this PR even if I still not sure about the @(objc_context_provider) attribute.

@gingerBill gingerBill merged commit 92df892 into odin-lang:master May 8, 2025
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants