From 884960a9f4cb4866bf73ae988489120923893ae5 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Thu, 1 Jul 2021 13:18:28 +0100 Subject: [PATCH 01/16] allow declaring weak properties --- rubicon/objc/api.py | 30 +++++++++++++++++++++++------- rubicon/objc/runtime.py | 27 ++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/rubicon/objc/api.py b/rubicon/objc/api.py index 41bc099f..7de8767e 100644 --- a/rubicon/objc/api.py +++ b/rubicon/objc/api.py @@ -368,26 +368,33 @@ class MySubclass(NSObject): the generated setter keeps the stored object retained, and releases it when it is replaced. In a custom Objective-C protocol, only the metadata for the property is generated. + + If ``weak`` is ``True``, the property will be created as a weak property. When assigning an object to it, + the reference count of the object will not be increased. When the object is deallocated, the property + value is set to None. """ - def __init__(self, vartype=objc_id): + def __init__(self, vartype=objc_id, weak=False): super().__init__() self.vartype = ctype_for_type(vartype) + self.weak = weak def _get_property_attributes(self): attrs = [ objc_property_attribute_t(b'T', encoding_for_ctype(self.vartype)), # Type: vartype ] if issubclass(self.vartype, objc_id): - attrs.append(objc_property_attribute_t(b'&', b'')) # retain + reference = b'W' if self.weak else b'&' + attrs.append(objc_property_attribute_t(reference, b'')) return (objc_property_attribute_t * len(attrs))(*attrs) def class_register(self, class_ptr, attr_name): add_ivar(class_ptr, '_' + attr_name, self.vartype) def _objc_getter(objc_self, _cmd): - value = get_ivar(objc_self, '_' + attr_name) + value = get_ivar(objc_self, '_' + attr_name, weak=self.weak) + # ctypes complains when a callback returns a "boxed" primitive type, so we have to manually unbox it. # If the data object has a value attribute and is not a structure or union, assume that it is # a primitive and unbox it. @@ -403,12 +410,21 @@ def _objc_setter(objc_self, _cmd, new_value): if not isinstance(new_value, self.vartype): # If vartype is a primitive, then new_value may be unboxed. If that is the case, box it manually. new_value = self.vartype(new_value) - old_value = get_ivar(objc_self, '_' + attr_name) - if issubclass(self.vartype, objc_id) and new_value: + + if issubclass(self.vartype, objc_id) and not self.weak: + old_value = get_ivar(objc_self, '_' + attr_name, weak=self.weak) + + if new_value.value == old_value.value: + # old and new value are the same, nothing to do + return + + if not self.weak and issubclass(self.vartype, objc_id) and new_value: # If the new value is a non-null object, retain it. send_message(new_value, 'retain', restype=objc_id, argtypes=[]) - set_ivar(objc_self, '_' + attr_name, new_value) - if issubclass(self.vartype, objc_id) and old_value: + + set_ivar(objc_self, '_' + attr_name, new_value, weak=self.weak) + + if not self.weak and issubclass(self.vartype, objc_id) and old_value: # If the old value is a non-null object, release it. send_message(old_value, 'release', restype=None, argtypes=[]) diff --git a/rubicon/objc/runtime.py b/rubicon/objc/runtime.py index 321f0f69..3fe4338b 100644 --- a/rubicon/objc/runtime.py +++ b/rubicon/objc/runtime.py @@ -361,6 +361,10 @@ class objc_property_attribute_t(Structure): libobjc.objc_allocateClassPair.restype = Class libobjc.objc_allocateClassPair.argtypes = [Class, c_char_p, c_size_t] +# id objc_autoreleaseReturnValue(id value) +libobjc.objc_autoreleaseReturnValue.restype = objc_id +libobjc.objc_autoreleaseReturnValue.argtypes = [objc_id] + # Protocol **objc_copyProtocolList(unsigned int *outCount) # Returns an array of *outcount pointers followed by NULL terminator. # You must free() the array. @@ -383,6 +387,14 @@ class objc_property_attribute_t(Structure): libobjc.objc_getProtocol.restype = objc_id libobjc.objc_getProtocol.argtypes = [c_char_p] +# id objc_loadWeakRetained(id *object) +libobjc.objc_loadWeakRetained.restype = objc_id +libobjc.objc_loadWeakRetained.argtypes = [c_void_p] + +# id objc_storeWeak(id *object, id value) +libobjc.objc_storeWeak.restype = objc_id +libobjc.objc_storeWeak.argtypes = [c_void_p, objc_id] + # You should set return and argument types depending on context. # id objc_msgSend(id theReceiver, SEL theSelector, ...) # id objc_msgSendSuper(struct objc_super *super, SEL op, ...) @@ -888,7 +900,7 @@ def add_ivar(cls, name, vartype): ) -def get_ivar(obj, varname): +def get_ivar(obj, varname, weak=False): """Get the value of obj's ivar named varname. The returned object is a :mod:`ctypes` data object. @@ -909,14 +921,17 @@ def get_ivar(obj, varname): ivar = libobjc.class_getInstanceVariable(libobjc.object_getClass(obj), ensure_bytes(varname)) vartype = ctype_for_encoding(libobjc.ivar_getTypeEncoding(ivar)) - if isinstance(vartype, objc_id): + if weak: + value = libobjc.objc_loadWeakRetained(obj.value + libobjc.ivar_getOffset(ivar)) + return libobjc.objc_autoreleaseReturnValue(value) + elif isinstance(vartype, objc_id): return cast(libobjc.object_getIvar(obj, ivar), vartype) else: return vartype.from_address(obj.value + libobjc.ivar_getOffset(ivar)) -def set_ivar(obj, varname, value): - """Set obj's ivar varname to value. +def set_ivar(obj, varname, value, weak=False): + """Set obj's ivar varname to value. If ``weak`` is ``True``, only a weak reference to the value is stored. value must be a :mod:`ctypes` data object whose type matches that of the ivar. """ @@ -940,7 +955,9 @@ def set_ivar(obj, varname, value): .format(varname, type(value), sizeof(type(value)), vartype, sizeof(vartype)) ) - if isinstance(vartype, objc_id): + if weak: + libobjc.objc_storeWeak(obj.value + libobjc.ivar_getOffset(ivar), value) + elif isinstance(vartype, objc_id): libobjc.object_setIvar(obj, ivar, value) else: memmove(obj.value + libobjc.ivar_getOffset(ivar), addressof(value), sizeof(vartype)) From 041061d861664764f04268781739b6f16b4bfc57 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Thu, 1 Jul 2021 13:18:41 +0100 Subject: [PATCH 02/16] add tests for strong and weak property lifetcycles --- tests/test_core.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 3a653713..9975b6c7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1003,6 +1003,47 @@ class Properties(NSObject): self.assertEqual(r.size.width, 56) self.assertEqual(r.size.height, 78) + def test_class_properties_lifecycle_strong(self): + + class StrongProperties(NSObject): + object = objc_property(ObjCInstance) + + pool = NSAutoreleasePool.alloc().init() + + properties = StrongProperties.alloc().init() + + obj = NSObject.alloc().init() + obj_pointer = obj.ptr.value # store the object pointer for future use + + properties.object = obj + + del obj + del pool + gc.collect() + + # assert that the object was retained by the property + self.assertEqual(properties.object.ptr.value, obj_pointer) + + def test_class_properties_lifecycle_weak(self): + + class WeakProperties(NSObject): + object = objc_property(ObjCInstance, weak=True) + + pool = NSAutoreleasePool.alloc().init() + + properties = WeakProperties.alloc().init() + + obj = NSObject.alloc().init() + properties.object = obj + + self.assertIs(properties.object, obj) + + del obj + del pool + gc.collect() + + self.assertIsNone(properties.object) + def test_class_with_wrapped_methods(self): """An ObjCClass can have wrapped methods.""" From 63ef1c3a9f32927f6eb8c4298f1ef3a89ca361d4 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Thu, 1 Jul 2021 13:18:47 +0100 Subject: [PATCH 03/16] add changelog entry --- changes/210.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/210.feature.rst diff --git a/changes/210.feature.rst b/changes/210.feature.rst new file mode 100644 index 00000000..f0471ef0 --- /dev/null +++ b/changes/210.feature.rst @@ -0,0 +1 @@ +Added support to declare weak properties on custom Objective-C classes. From e475b93262972587e3fa91a93998aa2a3456928f Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Thu, 1 Jul 2021 13:19:22 +0100 Subject: [PATCH 04/16] fix ivar access for objc_id types --- rubicon/objc/runtime.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rubicon/objc/runtime.py b/rubicon/objc/runtime.py index 3fe4338b..8870532b 100644 --- a/rubicon/objc/runtime.py +++ b/rubicon/objc/runtime.py @@ -924,7 +924,7 @@ def get_ivar(obj, varname, weak=False): if weak: value = libobjc.objc_loadWeakRetained(obj.value + libobjc.ivar_getOffset(ivar)) return libobjc.objc_autoreleaseReturnValue(value) - elif isinstance(vartype, objc_id): + elif issubclass(vartype, objc_id): return cast(libobjc.object_getIvar(obj, ivar), vartype) else: return vartype.from_address(obj.value + libobjc.ivar_getOffset(ivar)) @@ -957,7 +957,7 @@ def set_ivar(obj, varname, value, weak=False): if weak: libobjc.objc_storeWeak(obj.value + libobjc.ivar_getOffset(ivar), value) - elif isinstance(vartype, objc_id): + elif issubclass(vartype, objc_id): libobjc.object_setIvar(obj, ivar, value) else: memmove(obj.value + libobjc.ivar_getOffset(ivar), addressof(value), sizeof(vartype)) From f2da5ef922ab59dfd67b3fec3761a2db71a1d125 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Thu, 1 Jul 2021 14:34:02 +0100 Subject: [PATCH 05/16] change add_method -> replace_method this acts like add_method if the method does not exist, replaces it otherwise --- rubicon/objc/api.py | 12 ++++++------ rubicon/objc/runtime.py | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/rubicon/objc/api.py b/rubicon/objc/api.py index 7de8767e..db49e158 100644 --- a/rubicon/objc/api.py +++ b/rubicon/objc/api.py @@ -13,7 +13,7 @@ register_ctype_for_type ) from .runtime import ( - Class, SEL, add_ivar, add_method, ensure_bytes, get_class, get_ivar, libc, libobjc, objc_block, objc_id, + Class, SEL, add_ivar, replace_method, ensure_bytes, get_class, get_ivar, libc, libobjc, objc_block, objc_id, objc_property_attribute_t, object_isClass, set_ivar, send_message, send_super, ) @@ -279,7 +279,7 @@ def __call__(self, objc_self, objc_cmd, *args): def class_register(self, class_ptr, attr_name): name = attr_name.replace("_", ":") - add_method(class_ptr, name, self, self.encoding) + replace_method(class_ptr, name, self, self.encoding) def protocol_register(self, proto_ptr, attr_name): name = attr_name.replace('_', ':') @@ -315,7 +315,7 @@ def __call__(self, objc_cls, objc_cmd, *args): def class_register(self, class_ptr, attr_name): name = attr_name.replace("_", ":") - add_method(libobjc.object_getClass(class_ptr), name, self, self.encoding) + replace_method(libobjc.object_getClass(class_ptr), name, self, self.encoding) def protocol_register(self, proto_ptr, attr_name): name = attr_name.replace('_', ':') @@ -430,11 +430,11 @@ def _objc_setter(objc_self, _cmd, new_value): setter_name = 'set' + attr_name[0].upper() + attr_name[1:] + ':' - add_method( + replace_method( class_ptr, attr_name, _objc_getter, [self.vartype, ObjCInstance, SEL], ) - add_method( + replace_method( class_ptr, setter_name, _objc_setter, [None, ObjCInstance, SEL, self.vartype], ) @@ -475,7 +475,7 @@ def __call__(self, *args, **kwargs): def class_register(self, class_ptr, attr_name): name = attr_name.replace("_", ":") - add_method(class_ptr, name, self, self.encoding) + replace_method(class_ptr, name, self, self.encoding) def protocol_register(self, proto_ptr, attr_name): raise TypeError('Protocols cannot have method implementations, use objc_method instead of objc_rawmethod') diff --git a/rubicon/objc/runtime.py b/rubicon/objc/runtime.py index 8870532b..3431172a 100644 --- a/rubicon/objc/runtime.py +++ b/rubicon/objc/runtime.py @@ -18,7 +18,7 @@ 'Method', 'SEL', 'add_ivar', - 'add_method', + 'replace_method', 'get_class', 'get_ivar', 'libc', @@ -301,7 +301,7 @@ class objc_property_attribute_t(Structure): # IMP class_replaceMethod(Class cls, SEL name, IMP imp, const char *types) libobjc.class_replaceMethod.restype = IMP -libobjc.class_replaceMethod.argtypes = [Class, SEL, Ivar, c_char_p] +libobjc.class_replaceMethod.argtypes = [Class, SEL, IMP, c_char_p] # BOOL class_respondsToSelector(Class cls, SEL sel) libobjc.class_respondsToSelector.restype = c_bool @@ -858,8 +858,8 @@ def send_super(cls, receiver, selector, *args, restype=c_void_p, argtypes=None): _keep_alive_imps = [] -def add_method(cls, selector, method, encoding): - """Add a new instance method to the given class. +def replace_method(cls, selector, method, encoding): + """Add a new instance method to the given class or replace an existing instance method. To add a class method, add an instance method to the metaclass. @@ -886,7 +886,7 @@ def add_method(cls, selector, method, encoding): cfunctype = CFUNCTYPE(*signature) imp = cfunctype(method) - libobjc.class_addMethod(cls, selector, cast(imp, IMP), types) + libobjc.class_replaceMethod(cls, selector, cast(imp, IMP), types) _keep_alive_imps.append(imp) return imp From e9f2d7806b727386bb58743d500eaad21b209629 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Thu, 1 Jul 2021 14:34:13 +0100 Subject: [PATCH 06/16] clean up properties in dealloc --- rubicon/objc/api.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/rubicon/objc/api.py b/rubicon/objc/api.py index db49e158..0203f698 100644 --- a/rubicon/objc/api.py +++ b/rubicon/objc/api.py @@ -442,6 +442,28 @@ def _objc_setter(objc_self, _cmd, new_value): attrs = self._get_property_attributes() libobjc.class_addProperty(class_ptr, ensure_bytes(attr_name), attrs, len(attrs)) + # Add cleanup routines to dealloc. + + old_dealloc = libobjc.class_getMethodImplementation(class_ptr, SEL("dealloc")) + + def _new_delloc(objc_self, _cmd): + + # Clean up ivar. + if self.weak: + ivar = libobjc.class_getInstanceVariable(libobjc.object_getClass(objc_self), ('_' + attr_name).encode()) + libobjc.objc_storeWeak(objc_self.value + libobjc.ivar_getOffset(ivar), None) + elif issubclass(self.vartype, objc_id): + # If the old value is a non-null object, release it. The is no need to set the actual ivar to nil. + old_value = get_ivar(objc_self, '_' + attr_name, weak=self.weak) + send_message(old_value, 'release', restype=None, argtypes=[]) + + # Invoke original dealloc. + cfunctype = CFUNCTYPE(None, objc_id, SEL) + old_dealloc_callable = cast(old_dealloc, cfunctype) + old_dealloc_callable(objc_self, SEL("dealloc")) + + replace_method(class_ptr, 'dealloc', _new_delloc, [None, ObjCInstance, SEL]) + def protocol_register(self, proto_ptr, attr_name): attrs = self._get_property_attributes() libobjc.protocol_addProperty(proto_ptr, ensure_bytes(attr_name), attrs, len(attrs), True, True) From dd83d5d9c12788094e81ce7373739b1a5c1c4a00 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Thu, 1 Jul 2021 14:51:32 +0100 Subject: [PATCH 07/16] update docs --- docs/reference/rubicon-objc-runtime.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/rubicon-objc-runtime.rst b/docs/reference/rubicon-objc-runtime.rst index 9945d04b..300bacce 100644 --- a/docs/reference/rubicon-objc-runtime.rst +++ b/docs/reference/rubicon-objc-runtime.rst @@ -169,7 +169,7 @@ These utility functions provide easier access from Python to certain parts of th .. autofunction:: should_use_fpret .. autofunction:: send_message .. autofunction:: send_super -.. autofunction:: add_method +.. autofunction:: replace_method .. autofunction:: add_ivar .. autofunction:: get_ivar .. autofunction:: set_ivar From 2d50c47f4cb9c95565823a6f1685cbf9c643b470 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Thu, 1 Jul 2021 14:55:02 +0100 Subject: [PATCH 08/16] minor cleanup --- rubicon/objc/api.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/rubicon/objc/api.py b/rubicon/objc/api.py index 0203f698..cf37c0be 100644 --- a/rubicon/objc/api.py +++ b/rubicon/objc/api.py @@ -390,10 +390,13 @@ def _get_property_attributes(self): return (objc_property_attribute_t * len(attrs))(*attrs) def class_register(self, class_ptr, attr_name): - add_ivar(class_ptr, '_' + attr_name, self.vartype) + + ivar_name = '_' + attr_name + + add_ivar(class_ptr, ivar_name, self.vartype) def _objc_getter(objc_self, _cmd): - value = get_ivar(objc_self, '_' + attr_name, weak=self.weak) + value = get_ivar(objc_self, ivar_name, weak=self.weak) # ctypes complains when a callback returns a "boxed" primitive type, so we have to manually unbox it. # If the data object has a value attribute and is not a structure or union, assume that it is @@ -412,7 +415,7 @@ def _objc_setter(objc_self, _cmd, new_value): new_value = self.vartype(new_value) if issubclass(self.vartype, objc_id) and not self.weak: - old_value = get_ivar(objc_self, '_' + attr_name, weak=self.weak) + old_value = get_ivar(objc_self, ivar_name, weak=self.weak) if new_value.value == old_value.value: # old and new value are the same, nothing to do @@ -422,7 +425,7 @@ def _objc_setter(objc_self, _cmd, new_value): # If the new value is a non-null object, retain it. send_message(new_value, 'retain', restype=objc_id, argtypes=[]) - set_ivar(objc_self, '_' + attr_name, new_value, weak=self.weak) + set_ivar(objc_self, ivar_name, new_value, weak=self.weak) if not self.weak and issubclass(self.vartype, objc_id) and old_value: # If the old value is a non-null object, release it. @@ -450,11 +453,12 @@ def _new_delloc(objc_self, _cmd): # Clean up ivar. if self.weak: - ivar = libobjc.class_getInstanceVariable(libobjc.object_getClass(objc_self), ('_' + attr_name).encode()) + # Clean up weak reference. + ivar = libobjc.class_getInstanceVariable(libobjc.object_getClass(objc_self), ivar_name.encode()) libobjc.objc_storeWeak(objc_self.value + libobjc.ivar_getOffset(ivar), None) elif issubclass(self.vartype, objc_id): - # If the old value is a non-null object, release it. The is no need to set the actual ivar to nil. - old_value = get_ivar(objc_self, '_' + attr_name, weak=self.weak) + # If the old value is a non-null object, release it. There is no need to set the actual ivar to nil. + old_value = get_ivar(objc_self, ivar_name, weak=self.weak) send_message(old_value, 'release', restype=None, argtypes=[]) # Invoke original dealloc. From e606a402a48bc34a1ad8c92cee2b51b2ab0c8b10 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Tue, 6 Jul 2021 10:22:50 +0100 Subject: [PATCH 09/16] revert `replace_method` to `add_method` with replace kwarg --- docs/reference/rubicon-objc-runtime.rst | 2 +- rubicon/objc/api.py | 14 +++++++------- rubicon/objc/runtime.py | 17 +++++++++++++---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/reference/rubicon-objc-runtime.rst b/docs/reference/rubicon-objc-runtime.rst index 300bacce..9945d04b 100644 --- a/docs/reference/rubicon-objc-runtime.rst +++ b/docs/reference/rubicon-objc-runtime.rst @@ -169,7 +169,7 @@ These utility functions provide easier access from Python to certain parts of th .. autofunction:: should_use_fpret .. autofunction:: send_message .. autofunction:: send_super -.. autofunction:: replace_method +.. autofunction:: add_method .. autofunction:: add_ivar .. autofunction:: get_ivar .. autofunction:: set_ivar diff --git a/rubicon/objc/api.py b/rubicon/objc/api.py index cf37c0be..3d5f66a3 100644 --- a/rubicon/objc/api.py +++ b/rubicon/objc/api.py @@ -13,7 +13,7 @@ register_ctype_for_type ) from .runtime import ( - Class, SEL, add_ivar, replace_method, ensure_bytes, get_class, get_ivar, libc, libobjc, objc_block, objc_id, + Class, SEL, add_ivar, add_method, ensure_bytes, get_class, get_ivar, libc, libobjc, objc_block, objc_id, objc_property_attribute_t, object_isClass, set_ivar, send_message, send_super, ) @@ -279,7 +279,7 @@ def __call__(self, objc_self, objc_cmd, *args): def class_register(self, class_ptr, attr_name): name = attr_name.replace("_", ":") - replace_method(class_ptr, name, self, self.encoding) + add_method(class_ptr, name, self, self.encoding) def protocol_register(self, proto_ptr, attr_name): name = attr_name.replace('_', ':') @@ -315,7 +315,7 @@ def __call__(self, objc_cls, objc_cmd, *args): def class_register(self, class_ptr, attr_name): name = attr_name.replace("_", ":") - replace_method(libobjc.object_getClass(class_ptr), name, self, self.encoding) + add_method(libobjc.object_getClass(class_ptr), name, self, self.encoding) def protocol_register(self, proto_ptr, attr_name): name = attr_name.replace('_', ':') @@ -433,11 +433,11 @@ def _objc_setter(objc_self, _cmd, new_value): setter_name = 'set' + attr_name[0].upper() + attr_name[1:] + ':' - replace_method( + add_method( class_ptr, attr_name, _objc_getter, [self.vartype, ObjCInstance, SEL], ) - replace_method( + add_method( class_ptr, setter_name, _objc_setter, [None, ObjCInstance, SEL, self.vartype], ) @@ -466,7 +466,7 @@ def _new_delloc(objc_self, _cmd): old_dealloc_callable = cast(old_dealloc, cfunctype) old_dealloc_callable(objc_self, SEL("dealloc")) - replace_method(class_ptr, 'dealloc', _new_delloc, [None, ObjCInstance, SEL]) + add_method(class_ptr, 'dealloc', _new_delloc, [None, ObjCInstance, SEL], replace=True) def protocol_register(self, proto_ptr, attr_name): attrs = self._get_property_attributes() @@ -501,7 +501,7 @@ def __call__(self, *args, **kwargs): def class_register(self, class_ptr, attr_name): name = attr_name.replace("_", ":") - replace_method(class_ptr, name, self, self.encoding) + add_method(class_ptr, name, self, self.encoding) def protocol_register(self, proto_ptr, attr_name): raise TypeError('Protocols cannot have method implementations, use objc_method instead of objc_rawmethod') diff --git a/rubicon/objc/runtime.py b/rubicon/objc/runtime.py index 3431172a..ace7ca3e 100644 --- a/rubicon/objc/runtime.py +++ b/rubicon/objc/runtime.py @@ -18,7 +18,7 @@ 'Method', 'SEL', 'add_ivar', - 'replace_method', + 'add_method', 'get_class', 'get_ivar', 'libc', @@ -858,8 +858,8 @@ def send_super(cls, receiver, selector, *args, restype=c_void_p, argtypes=None): _keep_alive_imps = [] -def replace_method(cls, selector, method, encoding): - """Add a new instance method to the given class or replace an existing instance method. +def add_method(cls, selector, method, encoding, replace=False): + """Add a new instance method to the given class. To add a class method, add an instance method to the metaclass. @@ -868,6 +868,8 @@ def replace_method(cls, selector, method, encoding): :param method: The method implementation, as a Python callable or a C function address. :param encoding: The method's signature (return type and argument types) as a :class:`list`. The types of the implicit ``self`` and ``_cmd`` parameters must be included in the signature. + :param replace: If the class already implements a method with the given name, replaces the current implementation + if ``True``. Raises a :class:`ValueError` error otherwise. :return: The ctypes C function pointer object that was created for the method's implementation. This return value can be ignored. (In version 0.4.0 and older, callers were required to manually keep a reference to this function pointer object to ensure that it isn't garbage-collected. @@ -886,7 +888,14 @@ def replace_method(cls, selector, method, encoding): cfunctype = CFUNCTYPE(*signature) imp = cfunctype(method) - libobjc.class_replaceMethod(cls, selector, cast(imp, IMP), types) + if replace: + libobjc.class_replaceMethod(cls, selector, cast(imp, IMP), types) + else: + res = libobjc.class_addMethod(cls, selector, cast(imp, IMP), types) + + if not res: + raise ValueError("A method with the name {!r} already exists".format(selector.name)) + _keep_alive_imps.append(imp) return imp From ffa7467e14da43daed5664d1499352c930a42a88 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Tue, 6 Jul 2021 10:37:17 +0100 Subject: [PATCH 10/16] use `set_ivar` in dealloc --- rubicon/objc/api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rubicon/objc/api.py b/rubicon/objc/api.py index 3d5f66a3..77a482a8 100644 --- a/rubicon/objc/api.py +++ b/rubicon/objc/api.py @@ -454,8 +454,7 @@ def _new_delloc(objc_self, _cmd): # Clean up ivar. if self.weak: # Clean up weak reference. - ivar = libobjc.class_getInstanceVariable(libobjc.object_getClass(objc_self), ivar_name.encode()) - libobjc.objc_storeWeak(objc_self.value + libobjc.ivar_getOffset(ivar), None) + set_ivar(objc_self, ivar_name, self.vartype(None), weak=True) elif issubclass(self.vartype, objc_id): # If the old value is a non-null object, release it. There is no need to set the actual ivar to nil. old_value = get_ivar(objc_self, ivar_name, weak=self.weak) From 6701965db4a1c5ff48a78db1d120351c0e5cc2b1 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Tue, 6 Jul 2021 10:44:34 +0100 Subject: [PATCH 11/16] implement a single dealloc replacement for all properties --- rubicon/objc/api.py | 47 +++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/rubicon/objc/api.py b/rubicon/objc/api.py index 77a482a8..79882f0e 100644 --- a/rubicon/objc/api.py +++ b/rubicon/objc/api.py @@ -445,27 +445,18 @@ def _objc_setter(objc_self, _cmd, new_value): attrs = self._get_property_attributes() libobjc.class_addProperty(class_ptr, ensure_bytes(attr_name), attrs, len(attrs)) - # Add cleanup routines to dealloc. + def dealloc_callback(self, objc_self, attr_name): - old_dealloc = libobjc.class_getMethodImplementation(class_ptr, SEL("dealloc")) - - def _new_delloc(objc_self, _cmd): - - # Clean up ivar. - if self.weak: - # Clean up weak reference. - set_ivar(objc_self, ivar_name, self.vartype(None), weak=True) - elif issubclass(self.vartype, objc_id): - # If the old value is a non-null object, release it. There is no need to set the actual ivar to nil. - old_value = get_ivar(objc_self, ivar_name, weak=self.weak) - send_message(old_value, 'release', restype=None, argtypes=[]) - - # Invoke original dealloc. - cfunctype = CFUNCTYPE(None, objc_id, SEL) - old_dealloc_callable = cast(old_dealloc, cfunctype) - old_dealloc_callable(objc_self, SEL("dealloc")) + ivar_name = '_' + attr_name - add_method(class_ptr, 'dealloc', _new_delloc, [None, ObjCInstance, SEL], replace=True) + # Clean up ivar. + if self.weak: + # Clean up weak reference. + set_ivar(objc_self, ivar_name, self.vartype(None), weak=True) + elif issubclass(self.vartype, objc_id): + # If the old value is a non-null object, release it. There is no need to set the actual ivar to nil. + old_value = get_ivar(objc_self, ivar_name, weak=self.weak) + send_message(old_value, 'release', restype=None, argtypes=[]) def protocol_register(self, proto_ptr, attr_name): attrs = self._get_property_attributes() @@ -1009,6 +1000,24 @@ def _new_from_class_statement(cls, name, bases, attrs, *, protocols): else: class_register(ptr, attr_name) + # Add cleanup of ivars / properties to dealloc + + old_dealloc = libobjc.class_getMethodImplementation(ptr, SEL("dealloc")) + + def _new_delloc(objc_self, _cmd): + + # Invoke dealloc callback of each property. + for attr_name, obj in attrs.items(): + if isinstance(obj, objc_property): + obj.dealloc_callback(objc_self, attr_name) + + # Invoke original dealloc. + cfunctype = CFUNCTYPE(None, objc_id, SEL) + old_dealloc_callable = cast(old_dealloc, cfunctype) + old_dealloc_callable(objc_self, SEL("dealloc")) + + add_method(ptr, "dealloc", _new_delloc, [None, ObjCInstance, SEL], replace=True) + # Register the ObjC class libobjc.objc_registerClassPair(ptr) From eaf05868ac418621f0624882a783f752a8684ee1 Mon Sep 17 00:00:00 2001 From: SamSchott Date: Sun, 11 Jul 2021 00:38:14 +0100 Subject: [PATCH 12/16] Check all class attributes for dealloc callbacks Co-authored-by: dgelessus --- rubicon/objc/api.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rubicon/objc/api.py b/rubicon/objc/api.py index 79882f0e..a0ce5dab 100644 --- a/rubicon/objc/api.py +++ b/rubicon/objc/api.py @@ -1008,8 +1008,12 @@ def _new_delloc(objc_self, _cmd): # Invoke dealloc callback of each property. for attr_name, obj in attrs.items(): - if isinstance(obj, objc_property): - obj.dealloc_callback(objc_self, attr_name) + try: + dealloc_callback = obj.dealloc_callback + except AttributeError: + pass + else: + dealloc_callback(objc_self, attr_name) # Invoke original dealloc. cfunctype = CFUNCTYPE(None, objc_id, SEL) From 1b071f50279d6bf9b2278f46f98edf3cfb427771 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Wed, 14 Jul 2021 13:16:32 +0100 Subject: [PATCH 13/16] expand how-to section with notes on reference cycles --- docs/how-to/memory-management.rst | 70 +++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/how-to/memory-management.rst b/docs/how-to/memory-management.rst index 97dd9a77..339d6c31 100644 --- a/docs/how-to/memory-management.rst +++ b/docs/how-to/memory-management.rst @@ -13,6 +13,9 @@ When enabling automatic reference counting (ARC), the appropriate calls for memory management will be inserted for you at compile-time. However, since Rubicon Objective-C operates at runtime, it cannot make use of ARC. +Reference counting in Rubicon Objective-C +----------------------------------------- + You won't have to manage reference counts in Python, Rubicon Objective-C will do that work for you. It does so by tracking when you gain ownership of an object. This is the case when you create an Objective-C instance using a method @@ -51,3 +54,70 @@ weak reference to the object which is assigned to its delegate property: You will need to keep a reference to the Python variable ``delegate`` so that the corresponding Objective-C instance does not get deallocated. + +Reference cycles in Objective-C +------------------------------- + +Python has a garbage collector which detects references cycles and frees +objects in such cycles if no other references remain. Cyclical references can +be useful in a number of cases, for instance to refer to a "parent" of an +instance, and Python makes life easier by properly freeing such references. For +example: + +.. code-block:: python + + class TreeNode: + def __init__(self, val): + self.val = val + self.parent = None + self.children = [] + + + root = TreeNode("/home") + + child = TreeNode("/Documents") + child.parent = root + + root.children.append(child) + + # This will free both root and child on + # the next garbage collection cycle: + del root + del child + + +Similar code in Objective-C will lead to memory leaks. This also holds for +Objective-C instances created through Rubicon Objective-C since Python's +garbage collector is unable to detect reference cycles on the Objective-C side. +If you are writing code which would lead to reference cycles, consider storing +objects as weak references instead. The above code would be written as follows +when using Objective-C classes: + +.. code-block:: python + + from rubicon.objc import NSObject, NSMutableArray + from rubicon.objc.api import objc_property, objc_method + + + class TreeNode(NSObject): + val = objc_property() + children = objc_property() + parent = objc_property(weak=True) + + @objc_method + def initWithValue_(self, val): + self.val = val + self.children = NSMutableArray.new() + return self + + + root = TreeNode.alloc().initWithValue("/home") + + child = TreeNode.alloc().initWithValue("/Documents") + child.parent = root + + root.children.addObject(child) + + # This will free both root and child: + del root + del child From b3cdb754035cff0427f2c43698b14b642e778abe Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Fri, 16 Jul 2021 10:44:18 +0100 Subject: [PATCH 14/16] perform any user-defined dealloc before our own cleanup --- rubicon/objc/api.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/rubicon/objc/api.py b/rubicon/objc/api.py index a0ce5dab..4de4b9e0 100644 --- a/rubicon/objc/api.py +++ b/rubicon/objc/api.py @@ -993,20 +993,27 @@ def _new_from_class_statement(cls, name, bases, attrs, *, protocols): # Register all methods, properties, ivars, etc. for attr_name, obj in attrs.items(): - try: - class_register = obj.class_register - except AttributeError: - pass - else: - class_register(ptr, attr_name) + if attr_name != "dealloc": + try: + class_register = obj.class_register + except AttributeError: + pass + else: + class_register(ptr, attr_name) - # Add cleanup of ivars / properties to dealloc + # Register any user-defined dealloc method. We treat dealloc differently to + # inject our own cleanup code for properties, ivars, etc. - old_dealloc = libobjc.class_getMethodImplementation(ptr, SEL("dealloc")) + user_dealloc = attrs.get("dealloc", None) def _new_delloc(objc_self, _cmd): - # Invoke dealloc callback of each property. + # Invoke user-defined dealloc. + if user_dealloc: + user_dealloc(objc_self, _cmd) + + # Invoke dealloc callback of each attribute. Currently + # defined for properties only. for attr_name, obj in attrs.items(): try: dealloc_callback = obj.dealloc_callback @@ -1015,12 +1022,10 @@ def _new_delloc(objc_self, _cmd): else: dealloc_callback(objc_self, attr_name) - # Invoke original dealloc. - cfunctype = CFUNCTYPE(None, objc_id, SEL) - old_dealloc_callable = cast(old_dealloc, cfunctype) - old_dealloc_callable(objc_self, SEL("dealloc")) + # Invoke super dealloc. + send_super(ptr, objc_self, "dealloc", restype=None, argtypes=[]) - add_method(ptr, "dealloc", _new_delloc, [None, ObjCInstance, SEL], replace=True) + add_method(ptr, "dealloc", _new_delloc, [None, ObjCInstance, SEL]) # Register the ObjC class libobjc.objc_registerClassPair(ptr) @@ -1656,7 +1661,6 @@ def dealloc(self, cmd) -> None: address = get_ivar(self, 'wrapped_pointer') if address.value: del _keep_alive_objects[(self.value, address.value)] - send_super(__class__, self, 'dealloc', restype=None, argtypes=[]) @objc_rawmethod def finalize(self, cmd) -> None: From 1f7f74adc5e242110be9f5d0be0418c554266390 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Fri, 16 Jul 2021 11:29:35 +0100 Subject: [PATCH 15/16] add tests for proper dealloc behavior --- tests/test_core.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 9975b6c7..81860679 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1359,3 +1359,32 @@ def test_objcinstance_retain_release(self): # Delete it and make sure that we don't segfault on garbage collection. del string gc.collect() + + def test_objcinstance_dealloc(self): + + class DeallocTester(NSObject): + attr0 = objc_property() + attr1 = objc_property(weak=True) + + @objc_method + def dealloc(self): + self._did_dealloc = True + + obj = DeallocTester.alloc().init() + obj.__dict__["_did_dealloc"] = False + + attr0 = NSObject.alloc().init() + attr1 = NSObject.alloc().init() + + obj.attr0 = attr0 + obj.attr1 = attr1 + + self.assertEqual(attr0.retainCount(), 2) + self.assertEqual(attr1.retainCount(), 1) + + # ObjC object will be deallocated, can only access Python attributes afterwards. + obj.release() + + self.assertTrue(obj._did_dealloc, "custom dealloc did not run") + self.assertEqual(attr0.retainCount(), 1, "strong property value was not released") + self.assertEqual(attr1.retainCount(), 1, "weak property value was released") From 2a514ea2ae76220aeac00fa12714e55005405319 Mon Sep 17 00:00:00 2001 From: Sam Schott Date: Fri, 23 Jul 2021 11:15:31 +0100 Subject: [PATCH 16/16] warn the user when calling dealloc manually --- rubicon/objc/api.py | 2 +- rubicon/objc/runtime.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/rubicon/objc/api.py b/rubicon/objc/api.py index 4de4b9e0..a2b0e4a8 100644 --- a/rubicon/objc/api.py +++ b/rubicon/objc/api.py @@ -1023,7 +1023,7 @@ def _new_delloc(objc_self, _cmd): dealloc_callback(objc_self, attr_name) # Invoke super dealloc. - send_super(ptr, objc_self, "dealloc", restype=None, argtypes=[]) + send_super(ptr, objc_self, "dealloc", restype=None, argtypes=[], _allow_dealloc=True) add_method(ptr, "dealloc", _new_delloc, [None, ObjCInstance, SEL]) diff --git a/rubicon/objc/runtime.py b/rubicon/objc/runtime.py index ace7ca3e..b1e2cc87 100644 --- a/rubicon/objc/runtime.py +++ b/rubicon/objc/runtime.py @@ -1,4 +1,5 @@ import os +import warnings from ctypes import ( ArgumentError, CDLL, CFUNCTYPE, POINTER, Structure, Union, addressof, alignment, byref, c_bool, c_char_p, c_double, c_float, c_int, c_longdouble, c_size_t, c_uint, c_uint8, c_void_p, cast, memmove, sizeof, util, @@ -766,7 +767,7 @@ class objc_super(Structure): # http://stackoverflow.com/questions/3095360/what-exactly-is-super-in-objective-c -def send_super(cls, receiver, selector, *args, restype=c_void_p, argtypes=None): +def send_super(cls, receiver, selector, *args, restype=c_void_p, argtypes=None, _allow_dealloc=False): """In the context of the given class, call a superclass method on the receiver with the given selector and arguments. @@ -802,6 +803,9 @@ def send_super(cls, receiver, selector, *args, restype=c_void_p, argtypes=None): except AttributeError: pass + # Convert str / bytes to selector + selector = SEL(selector) + if not isinstance(cls, Class): # Kindly remind the caller that the API has changed raise TypeError( @@ -811,6 +815,14 @@ def send_super(cls, receiver, selector, *args, restype=c_void_p, argtypes=None): .format(tp=type(cls)) ) + if not _allow_dealloc and selector.name == b"dealloc": + warnings.warn( + "You should not call the superclass dealloc manually when overriding dealloc. Rubicon-objc " + "will call it for you after releasing objects stored in properties and ivars.", + stacklevel=2 + ) + return + try: receiver = receiver._as_parameter_ except AttributeError: @@ -830,7 +842,6 @@ def send_super(cls, receiver, selector, *args, restype=c_void_p, argtypes=None): .format(libobjc.class_getName(cls).decode('utf-8')) ) super_struct = objc_super(receiver, super_ptr) - selector = SEL(selector) if argtypes is None: argtypes = []