diff --git a/changes/256.feature.rst b/changes/256.feature.rst new file mode 100644 index 00000000..5430b4fc --- /dev/null +++ b/changes/256.feature.rst @@ -0,0 +1,5 @@ +Retain Objective-C objects when creating Python wrappers and release them when the +Python wrapped is garbage collected. This means that manual ``retain`` calls and +subsequent ``release`` or ``autorelease`` calls from Python are no longer needed with +very few exceptions, for example when writing implementations of ``copy`` that return an +existing object. diff --git a/changes/256.removal.rst b/changes/256.removal.rst new file mode 100644 index 00000000..92d3ecce --- /dev/null +++ b/changes/256.removal.rst @@ -0,0 +1,5 @@ +Manual calls to ``release`` or ``autorelease`` no longer cause Rubicon +to skip releasing an Objective-C object when its Python wrapper is +garbage collected. This means that fewer ``retain`` than ``release`` calls will cause +segfaults on garbage collection. Review your code carefully for unbalanced ``retain`` +and ``release`` calls before updating. diff --git a/docs/how-to/memory-management.rst b/docs/how-to/memory-management.rst index 37b897d5..12f916d7 100644 --- a/docs/how-to/memory-management.rst +++ b/docs/how-to/memory-management.rst @@ -2,6 +2,9 @@ Memory management for Objective-C instances =========================================== +Reference counting in Objective-C +================================= + Reference counting works differently in Objective-C compared to Python. Python will automatically track where variables are referenced and free memory when the reference count drops to zero whereas Objective-C uses explicit reference @@ -13,28 +16,36 @@ 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 ------------------------------------------ +Reference management in Rubicon +=============================== + +In most cases, you won't have to manage reference counts in Python, Rubicon +Objective-C will do that work for you. It does so by calling ``retain`` on an +object when Rubicon creates a ``ObjCInstance`` for it on the Python side, and calling +``autorelease`` when the ``ObjCInstance`` is garbage collected in Python. Retaining +the object ensures it is not deallocated while it is still referenced from Python +and releasing it again on ``__del__`` ensures that we do not leak memory. + +The only exception to this is when you create an object -- which is always done +through methods starting with "alloc", "new", "copy", or "mutableCopy". Rubicon does +not explicitly retain such objects because we own objects created by us, but Rubicon +does autorelease them when the Python wrapper is garbage collected. -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 whose -name begins with ``alloc``, ``new``, ``copy``, or ``mutableCopy``. Rubicon -Objective-C will then insert a ``release`` call when the Python variable that -corresponds to the Objective-C instance is deallocated. +Rubicon Objective-C will not keep track if you additionally manually ``retain`` an +object. You will be responsible to insert appropriate ``release`` or ``autorelease`` +calls yourself to prevent leaking memory. -An exception to this is when you manually ``retain`` an object. Rubicon -Objective-C will not keep track of such retain calls and you will be -responsible to insert appropriate ``release`` calls yourself. +Weak references in Objective-C +------------------------------ -You will also need to pay attention to reference counting in case of **weak -references**. In Objective-C, creating a **weak reference** means that the -reference count of the object is not incremented and the object will still be +You will need to pay attention to reference counting in case of **weak +references**. In Objective-C, as in Python, creating a weak reference means that +the reference count of the object is not incremented and the object will be deallocated when no strong references remain. Any weak references to the object are then set to ``nil``. -Some objects will store references to other objects as a weak reference. Such -properties will be declared in the Apple developer documentation as +Some Objective-C objects store references to other objects as a weak reference. +Such properties will be declared in the Apple developer documentation as "@property(weak)" or "@property(assign)". This is commonly the case for delegates. For example, in the code below, the ``NSOutlineView`` only stores a weak reference to the object which is assigned to its delegate property: diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index b5ed95f3..18050a76 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -1,6 +1,9 @@ Alea +alloc Autorelease +autorelease autoreleased +autoreleases Bugfixes callables CPython @@ -22,6 +25,7 @@ lookups macOS metaclass metaclasses +mutableCopy namespace namespaces ObjC diff --git a/src/rubicon/objc/api.py b/src/rubicon/objc/api.py index 96104a29..a4349cfa 100644 --- a/src/rubicon/objc/api.py +++ b/src/rubicon/objc/api.py @@ -91,6 +91,49 @@ # the Python objects are not destroyed if they are otherwise no Python references left. _keep_alive_objects = {} +# Methods that return an object which is implicitly retained by the caller. +# See https://clang.llvm.org/docs/AutomaticReferenceCounting.html#semantics-of-method-families. +_RETURNS_RETAINED_FAMILIES = {"init", "alloc", "new", "copy", "mutableCopy"} + + +def get_method_family(method_name: str) -> str: + """Returns the method family from the method name. See + https://clang.llvm.org/docs/AutomaticReferenceCounting.html#method-families for + documentation on method families and corresponding selector names.""" + first_component = method_name.lstrip("_").split(":")[0] + for family in _RETURNS_RETAINED_FAMILIES: + if first_component.startswith(family): + remainder = first_component.removeprefix(family) + if remainder == "" or not remainder[0].islower(): + return family + + return "" + + +def method_name_to_tuple(name: str) -> (str, tuple[str, ...]): + """ + Performs the following transformation: + + "methodWithArg0:withArg1:withArg2:" -> "methodWithArg0", ("", "withArg1", "withArg2") + "methodWithArg0:" -> "methodWithArg0", ("", ) + "method" -> "method", () + + The first element of the returned tuple is the "base name" of the method. The second + element is a tuple with its argument names. + """ + # Selectors end with a colon if the method takes arguments. + if name.endswith(":"): + first, *rest, _ = name.split(":") + # Insert an empty string in order to indicate that the method + # takes a first argument as a positional argument. + rest.insert(0, "") + rest = tuple(rest) + else: + first = name + rest = () + + return first, rest + def encoding_from_annotation(f, offset=1): argspec = inspect.getfullargspec(inspect.unwrap(f)) @@ -210,6 +253,17 @@ def __call__(self, receiver, *args, convert_args=True, convert_result=True): else: converted_args = args + # Init methods consume their `self` argument (the receiver), see + # https://clang.llvm.org/docs/AutomaticReferenceCounting.html#semantics-of-init. + # To ensure the receiver pointer remains valid if `init` does not return `self` + # but a different object or None, we issue an additional retain. This needs to + # be done before calling the method. + # Note that if `init` does return the same object, it will already be in our + # cache and balanced with a `release` on cache retrieval. + method_family = get_method_family(self.name.decode()) + if method_family == "init": + send_message(receiver, "retain", restype=objc_id, argtypes=[]) + result = send_message( receiver, self.selector, @@ -222,13 +276,11 @@ def __call__(self, receiver, *args, convert_args=True, convert_result=True): return result # Convert result to python type if it is an instance or class pointer. + # Explicitly retain the instance on first handover to Python unless we + # received it from a method that gives us ownership already. if self.restype is not None and issubclass(self.restype, objc_id): - result = ObjCInstance(result) - - # Mark for release if we acquire ownership of an object. Do not autorelease here because - # we might retain a Python reference while the Obj-C reference goes out of scope. - if self.name.startswith((b"alloc", b"new", b"copy", b"mutableCopy")): - result._needs_release = True + implicitly_owned = method_family in _RETURNS_RETAINED_FAMILIES + result = ObjCInstance(result, _implicitly_owned=implicitly_owned) return result @@ -240,7 +292,10 @@ def __init__(self, name_start): super().__init__() self.name_start = name_start - self.methods = {} # Initialized in ObjCClass._load_methods + + # A dictionary mapping from a tuple of argument names to the full method name. + # Initialized in ObjCClass._load_methods + self.methods: dict[tuple[str, ...], str] = {} def __repr__(self): return f"{type(self).__qualname__}({self.name_start!r})" @@ -258,21 +313,31 @@ def __call__(self, receiver, first_arg=_sentinel, **kwargs): args.insert(0, first_arg) rest = ("",) + order + # Try to use cached ObjCBoundMethod try: name = self.methods[rest] + meth = receiver.objc_class._cache_method(name) + return meth(receiver, *args) except KeyError: - if first_arg is self._sentinel: - specified_sel = self.name_start - else: - specified_sel = f"{self.name_start}:{':'.join(kwargs.keys())}:" - raise ValueError( - f"Invalid selector {specified_sel}. Available selectors are: " - f"{', '.join(sel for sel in self.methods.values())}" - ) from None + pass + + # Reconstruct the full method name from arguments and look up actual method. + if first_arg is self._sentinel: + name = self.name_start + else: + name = f"{self.name_start}:{':'.join(kwargs.keys())}:" meth = receiver.objc_class._cache_method(name) - return meth(receiver, *args) + if meth: + # Update methods cache and call method. + self.methods[rest] = name + return meth(receiver, *args) + + raise ValueError( + f"Invalid selector {name}. Available selectors are: " + f"{', '.join(sel for sel in self.methods.values())}" + ) from None class ObjCBoundMethod: @@ -783,29 +848,13 @@ def objc_class(self): return super(ObjCInstance, type(self)).__getattribute__(self, "_objc_class") except AttributeError: # This assumes that objects never change their class after they are - # seen by Rubicon. There are two reasons why this may not be true: - # - # 1. Objective-C runtime provides a function object_setClass that - # can change an object's class after creation, and some code - # manipulates objects' isa pointers directly (although the latter - # is no longer officially supported by Apple). This is not - # commonly done in practice, and even then it is usually only - # done during object creation/initialization, so it's basically - # safe to assume that an object's class will never change after - # it's been wrapped in an ObjCInstance. - # 2. If a memory address is freed by the Objective-C runtime, and - # then re-allocated by an object of a different type, but the - # Python ObjCInstance wrapper persists, Python will assume the - # object is still of the old type. If a new ObjCInstance wrapper - # for the same pointer is re-created, a check is performed to - # ensure the type hasn't changed; this problem only affects - # pre-existing Python wrappers. If this occurs, it probably - # indicates an issue with the retain count on the Python side (as - # the Objective-C runtime shouldn't be able to dispose of an - # object if Python still has a handle to it). If this *does* - # happen, it will manifest as objects appearing to be the wrong - # type, and/or objects having the wrong list of attributes - # available. Refs #249. + # seen by Rubicon. This can occur because the Objective-C runtime provides a + # function object_setClass that can change an object's class after creation, + # and some code manipulates objects' isa pointers directly (although the + # latter is no longer officially supported by Apple). This is not commonly + # done in practice, and even then it is usually only done during object + # creation/initialization, so it's basically safe to assume that an object's + # class will never change after it's been wrapped in an ObjCInstance. super(ObjCInstance, type(self)).__setattr__( self, "_objc_class", ObjCClass(libobjc.object_getClass(self)) ) @@ -815,7 +864,9 @@ def objc_class(self): def _associated_attr_key_for_name(name): return SEL(f"rubicon.objc.py_attr.{name}") - def __new__(cls, object_ptr, _name=None, _bases=None, _ns=None): + def __new__( + cls, object_ptr, _name=None, _bases=None, _ns=None, _implicitly_owned=False + ): """The constructor accepts an :class:`~rubicon.objc.runtime.objc_id` or anything that can be cast to one, such as a :class:`~ctypes.c_void_p`, or an existing :class:`ObjCInstance`. @@ -833,10 +884,17 @@ class or a metaclass, an instance of :class:`ObjCClass` or :func:`register_type_for_objcclass`. Creating an :class:`ObjCInstance` from a ``nil`` pointer returns ``None``. - Rubicon currently does not perform any automatic memory management on - the Objective-C object wrapped in an :class:`ObjCInstance`. It is the - user's responsibility to ``retain`` and ``release`` wrapped objects as - needed, like in Objective-C code without automatic reference counting. + Rubicon retains an Objective-C object when it is wrapped in an + :class:`ObjCInstance` and autoreleases it when the :class:`ObjCInstance` is + garbage collected. + + The only exception to this are objects returned by methods which create an + object (starting with "alloc", "new", "copy", or "mutableCopy"). We do not + explicitly retain them because we already own objects created by us, but we do + autorelease them on garbage collection of the Python wrapper. + + This ensures that the :class:`ObjCInstance` can always be used from Python + without segfaults while preventing Rubicon from leaking memory. """ # Make sure that object_ptr is wrapped in an objc_id. @@ -852,68 +910,31 @@ class or a metaclass, an instance of :class:`ObjCClass` or # If an ObjCInstance already exists for the Objective-C object, # reuse it instead of creating a second ObjCInstance for the # same object. - cached = cls._cached_objects[object_ptr.value] - - # In a high-churn environment, it is possible for an object to - # be deallocated, and the same memory address be re-used on the - # Objective-C side, but the Python wrapper object for the - # original instance has *not* been cleaned up. In that - # situation, an attempt to wrap the *new* Objective-C object - # instance will cause a false positive cache hit; returning a - # Python object that has a class that doesn't match the class of - # the new instance. - # - # To prevent this, when we get a cache hit on an ObjCInstance, - # use the raw Objective-C API on the pointer to get the current - # class of the object referred to by the pointer. If there's a - # discrepancy, purge the cache for the memory address, and - # re-create the object. - # - # We do this both when the type *is* ObjCInstance (the case when - # instantiating a literal ObjCInstance()), and when type is an - # ObjCClass instance (e.g., ObjClass("Example"), which is the - # type of a directly instantiated instance of Example. - # - # We *don't* do this when the type *is* ObjCClass, - # ObjCMetaClass, as there's a race condition on startup - - # retrieving `.objc_class` causes the creation of ObjCClass - # objects, which will cause cache hits trying to re-use existing - # ObjCClass objects. However, ObjCClass instances generally - # won't be recycled or reused, so that should be safe to exclude - # from the cache freshness check. + cached_obj = cls._cached_objects[object_ptr.value] + + # We can get a cache hit for methods that return an implicitly retained + # object. This is typically the case when: # - # One edge case with this approach: if the old and new - # Objective-C objects have the same class, they won't be - # identified as a stale object, and they'll re-use the same - # Python wrapper. This effectively means id(obj) isn't a - # reliable instance identifier... but (a) this won't be a common - # case; (b) this flaw exists in pure Python and Objective-C as - # well, because default object identity is tied to memory - # allocation; and (c) the stale wrapper will *work*, because - # it's the correct class. + # 1. A `copy` returns the original object if it is immutable. This is + # typically done for optimization. See + # https://developer.apple.com/documentation/foundation/nscopying. + # 2. An `init` call returns an object which we already own from a + # previous `alloc` call. See `init` handling in ObjCMethod. __call__. # - # Refs #249. - if cls == ObjCInstance or isinstance(cls, ObjCInstance): - cached_class_name = cached.objc_class.name - current_class_name = libobjc.class_getName( - libobjc.object_getClass(object_ptr) - ).decode("utf-8") - if ( - current_class_name != cached_class_name - and not current_class_name.endswith(f"_{cached_class_name}") - ): - # There has been a cache hit, but the object is a - # different class, treat this as a cache miss. We don't - # *just* look for an *exact* class name match, because - # some Cocoa/UIKit classes undergo a class name change - # between `alloc()` and `init()` (e.g., `NSWindow` - # becomes `NSKVONotifying_NSWindow`). Refs #257. - raise KeyError(object_ptr.value) - - return cached + # If the object is already in our cache, we end up owning more than one + # refcount. We release this additional refcount to prevent memory leaks. + if _implicitly_owned: + send_message(object_ptr, "release", restype=objc_id, argtypes=[]) + + return cached_obj except KeyError: pass + # Explicitly retain the instance on first handover to Python unless we + # received it from a method that gives us ownership already. + if not _implicitly_owned: + send_message(object_ptr, "retain", restype=objc_id, argtypes=[]) + # If the given pointer points to a class, return an ObjCClass instead (if we're not already creating one). if not issubclass(cls, ObjCClass) and object_isClass(object_ptr): return ObjCClass(object_ptr) @@ -932,7 +953,6 @@ class or a metaclass, an instance of :class:`ObjCClass` or super(ObjCInstance, type(self)).__setattr__( self, "_as_parameter_", object_ptr ) - super(ObjCInstance, type(self)).__setattr__(self, "_needs_release", False) if isinstance(object_ptr, objc_block): super(ObjCInstance, type(self)).__setattr__( self, "block", ObjCBlock(object_ptr) @@ -944,39 +964,16 @@ class or a metaclass, an instance of :class:`ObjCClass` or return self - def release(self): - """Manually decrement the reference count of the corresponding objc - object. - - The objc object is sent a dealloc message when its reference - count reaches 0. Calling this method manually should not be - necessary, unless the object was explicitly ``retain``\\ed - before. Objects returned from ``.alloc().init...(...)`` and - similar calls are released automatically by Rubicon when the - corresponding Python object is deallocated. - """ - self._needs_release = False - send_message(self, "release", restype=objc_id, argtypes=[]) - - def autorelease(self): - """Decrements the receiver’s reference count at the end of the current - autorelease pool block. - - The objc object is sent a dealloc message when its reference - count reaches 0. If called, the object will not be released when - the Python object is deallocated. - """ - self._needs_release = False - result = send_message(self, "autorelease", restype=objc_id, argtypes=[]) - return ObjCInstance(result) - def __del__(self): - """Release the corresponding objc instance if we own it, i.e., if it - was returned by a method starting with :meth:`alloc`, :meth:`new`, - :meth:`copy`, or :meth:`mutableCopy` and it wasn't already explicitly - released by calling :meth:`release` or :meth:`autorelease`.""" - if self._needs_release: - send_message(self, "release", restype=objc_id, argtypes=[]) + # Autorelease our reference on garbage collection of the Python wrapper. We use + # autorelease instead of release to allow ObjC to take ownership of an object when + # it is returned from a factory method. + try: + send_message(self, "autorelease", restype=objc_id, argtypes=[]) + except (NameError, TypeError): + # Handle interpreter shutdown gracefully where send_message might be deleted + # (NameError) or set to None (TypeError). + pass def __str__(self): """Get a human-readable representation of ``self``. @@ -1623,44 +1620,40 @@ def _load_methods(self): if self.methods_ptr is not None: raise RuntimeError(f"{self}._load_methods cannot be called more than once") - methods_ptr_count = c_uint(0) - - methods_ptr = libobjc.class_copyMethodList(self, byref(methods_ptr_count)) + # Traverse superclasses and load methods. + superclass = self.superclass - if self.superclass is not None: - if self.superclass.methods_ptr is None: - with self.superclass.cache_lock: - self.superclass._load_methods() + while superclass is not None: + if superclass.methods_ptr is None: + with superclass.cache_lock: + superclass._load_methods() # Prime this class' partials list with a list from the superclass. - for first, superpartial in self.superclass.partial_methods.items(): + for first, superpartial in superclass.partial_methods.items(): partial = ObjCPartialMethod(first) self.partial_methods[first] = partial partial.methods.update(superpartial.methods) + superclass = superclass.superclass + + # Load methods for this class. + methods_ptr_count = c_uint(0) + methods_ptr = libobjc.class_copyMethodList(self, byref(methods_ptr_count)) + for i in range(methods_ptr_count.value): method = methods_ptr[i] name = libobjc.method_getName(method).name.decode("utf-8") self.instance_method_ptrs[name] = method - # Selectors end with a colon if the method takes arguments. - if name.endswith(":"): - first, *rest, _ = name.split(":") - # Insert an empty string in order to indicate that the method - # takes a first argument as a positional argument. - rest.insert(0, "") - rest = tuple(rest) - else: - first = name - rest = () + base_name, argument_names = method_name_to_tuple(name) try: - partial = self.partial_methods[first] + partial = self.partial_methods[base_name] except KeyError: - partial = ObjCPartialMethod(first) - self.partial_methods[first] = partial + partial = ObjCPartialMethod(base_name) + self.partial_methods[base_name] = partial - partial.methods[rest] = name + partial.methods[argument_names] = name # Set the list of methods for the class to the computed list. self.methods_ptr = methods_ptr diff --git a/tests/test_core.py b/tests/test_core.py index 4fdedebd..47454c5a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -6,6 +6,7 @@ import sys import threading import unittest +import uuid import weakref from ctypes import ( ArgumentError, @@ -21,6 +22,7 @@ ) from decimal import Decimal from enum import Enum +from typing import Callable from rubicon.objc import ( SEL, @@ -31,6 +33,7 @@ NSEdgeInsets, NSEdgeInsetsMake, NSMakeRect, + NSMutableArray, NSObject, NSObjectProtocol, NSPoint, @@ -54,11 +57,29 @@ send_super, types, ) -from rubicon.objc.runtime import autoreleasepool, get_ivar, libobjc, objc_id, set_ivar +from rubicon.objc.api import get_method_family +from rubicon.objc.runtime import ( + autoreleasepool, + get_ivar, + libobjc, + load_library, + objc_id, + set_ivar, +) from rubicon.objc.types import __LP64__ from . import OSX_VERSION, rubiconharness +appkit = load_library("AppKit") + +NSArray = ObjCClass("NSArray") +NSImage = ObjCClass("NSImage") +NSString = ObjCClass("NSString") + + +class ObjcWeakref(NSObject): + weak_property = objc_property(weak=True) + class struct_int_sized(Structure): _fields_ = [("x", c_char * 4)] @@ -72,6 +93,26 @@ class struct_large(Structure): _fields_ = [("x", c_char * 17)] +def assert_lifecycle( + test: unittest.TestCase, object_constructor: Callable[[], ObjCInstance] +) -> None: + obj = object_constructor() + + wr = ObjcWeakref.alloc().init() + wr.weak_property = obj + + with autoreleasepool(): + del obj + gc.collect() + + test.assertIsNotNone( + wr.weak_property, + "object was deallocated before end of autorelease pool", + ) + + test.assertIsNone(wr.weak_property, "object was not deallocated") + + class RubiconTest(unittest.TestCase): def test_sel_by_name(self): self.assertEqual(SEL(b"foobar").name, b"foobar") @@ -272,10 +313,6 @@ def test_objcprotocol_protocols(self): def test_objcclass_instancecheck(self): """isinstance works with an ObjCClass as the second argument.""" - - NSArray = ObjCClass("NSArray") - NSString = ObjCClass("NSString") - self.assertIsInstance(NSObject.new(), NSObject) self.assertIsInstance(at(""), NSString) self.assertIsInstance(at(""), NSObject) @@ -288,10 +325,6 @@ def test_objcclass_instancecheck(self): def test_objcclass_subclasscheck(self): """issubclass works with an ObjCClass as the second argument.""" - - NSArray = ObjCClass("NSArray") - NSString = ObjCClass("NSString") - self.assertTrue(issubclass(NSObject, NSObject)) self.assertTrue(issubclass(NSString, NSObject)) self.assertTrue(issubclass(NSObject.objc_class, NSObject)) @@ -323,8 +356,6 @@ def test_objcprotocol_instancecheck(self): def test_objcprotocol_subclasscheck(self): """issubclass works with an ObjCProtocol as the second argument.""" - - NSString = ObjCClass("NSString") NSCopying = ObjCProtocol("NSCopying") NSCoding = ObjCProtocol("NSCoding") NSSecureCoding = ObjCProtocol("NSSecureCoding") @@ -442,8 +473,6 @@ def test_method_incorrect_argument_count_send(self): def test_method_varargs_send(self): """A variadic method can be called using send_message.""" - - NSString = ObjCClass("NSString") formatted = send_message( NSString, "stringWithFormat:", @@ -1409,7 +1438,8 @@ class Ivars(NSObject): self.assertEqual(r.size.height, 78) def test_class_properties(self): - """A Python class can have ObjC properties with synthesized getters and setters.""" + """A Python class can have ObjC properties with synthesized getters and setters + of ObjCInstance type.""" NSURL = ObjCClass("NSURL") @@ -1459,6 +1489,9 @@ def getSchemeIfPresent(self): self.assertIsNone(box.data) def test_class_python_properties(self): + """A Python class can have ObjC properties with synthesized getters and setters + of Python type.""" + class PythonObjectProperties(NSObject): object = objc_property(object) @@ -1494,9 +1527,10 @@ class PythonObject: properties.object = o self.assertIs(properties.object, o) - del o - del properties - gc.collect() + with autoreleasepool(): + del o + del properties + gc.collect() self.assertIsNone(wr()) @@ -1859,76 +1893,136 @@ class TestO: self.assertIsNone(wr_python_object()) - def test_objcinstance_release_owned(self): - # Create an object which we own. - obj = NSObject.alloc().init() + def test_objcinstance_returned_lifecycle(self): + """An object is retained when creating an ObjCInstance for it without implicit + ownership. It is autoreleased when the ObjCInstance is garbage collected. + """ - # Check that it is marked for release. - self.assertTrue(obj._needs_release) + def create_object(): + with autoreleasepool(): + return NSString.stringWithString(str(uuid.uuid4())) - # Explicitly release the object. - obj.release() + assert_lifecycle(self, create_object) - # Check that we no longer need to release it. - self.assertFalse(obj._needs_release) + def test_objcinstance_alloc_lifecycle(self): + """We properly retain and release objects that are allocated but never + initialized.""" - # Delete it and make sure that we don't segfault on garbage collection. - del obj - gc.collect() + def create_object(): + with autoreleasepool(): + return NSObject.alloc() - def test_objcinstance_autorelease_owned(self): - # Create an object which we own. - obj = NSObject.alloc().init() + assert_lifecycle(self, create_object) - # Check that it is marked for release. - self.assertTrue(obj._needs_release) + def test_objcinstance_alloc_init_lifecycle(self): + """An object is not additionally retained when we create and initialize it + through an alloc().init() chain. It is autoreleased when the ObjCInstance is + garbage collected. + """ - # Explicitly release the object. - res = obj.autorelease() + def create_object(): + return NSObject.alloc().init() - # Check that autorelease call returned the object itself. - self.assertIs(obj, res) + assert_lifecycle(self, create_object) - # Check that we no longer need to release it. - self.assertFalse(obj._needs_release) + def test_objcinstance_new_lifecycle(self): + """An object is not additionally retained when we create and initialize it with + a new call. It is autoreleased when the ObjCInstance is garbage collected. + """ - # Delete it and make sure that we don't segfault on garbage collection. - del obj - gc.collect() + def create_object(): + return NSObject.new() - def test_objcinstance_retain_release(self): - NSString = ObjCClass("NSString") + assert_lifecycle(self, create_object) - # Create an object which we don't own. - string = NSString.stringWithString("test") + def test_objcinstance_copy_lifecycle(self): + """An object is not additionally retained when we create and initialize it with + a copy call. It is autoreleased when the ObjCInstance is garbage collected. + """ - # Check that it is not marked for release. - self.assertFalse(string._needs_release) + def create_object(): + obj = NSMutableArray.alloc().init() + copy = obj.copy() - # Explicitly retain the object. - res = string.retain() + # Check that the copy is a new object. + self.assertIsNot(obj, copy) + self.assertNotEqual(obj.ptr.value, copy.ptr.value) - # Check that autorelease call returned the object itself. - self.assertIs(string, res) + return copy - # Manually release the object. - string.release() + assert_lifecycle(self, create_object) - # Delete it and make sure that we don't segfault on garbage collection. - del string - gc.collect() + def test_objcinstance_mutable_copy_lifecycle(self): + """An object is not additionally retained when we create and initialize it with + a mutableCopy call. It is autoreleased when the ObjCInstance is garbage collected. + """ + + def create_object(): + obj = NSMutableArray.alloc().init() + copy = obj.mutableCopy() + + # Check that the copy is a new object. + self.assertIsNot(obj, copy) + self.assertNotEqual(obj.ptr.value, copy.ptr.value) + + return copy + + assert_lifecycle(self, create_object) + + def test_objcinstance_immutable_copy_lifecycle(self): + """If the same object is returned from multiple creation methods, it is still + freed on Python garbage collection.""" + + def create_object(): + with autoreleasepool(): + obj = NSString.stringWithString(str(uuid.uuid4())) + copy = obj.copy() + + # Check that the copy the same object as the original. + self.assertIs(obj, copy) + self.assertEqual(obj.ptr.value, copy.ptr.value) + + return obj + + assert_lifecycle(self, create_object) + + def test_objcinstance_init_change_lifecycle(self): + """We do not leak memory if init returns a different object than it + received in alloc.""" + + def create_object(): + with autoreleasepool(): + obj_allocated = NSString.alloc() + obj_initialized = obj_allocated.initWithString(str(uuid.uuid4())) + + # Check that the initialized object is a different one than the allocated. + self.assertIsNot(obj_allocated, obj_initialized) + self.assertNotEqual(obj_allocated.ptr.value, obj_initialized.ptr.value) + + return obj_initialized + + assert_lifecycle(self, create_object) + + def test_objcinstance_init_none(self): + """We do not segfault if init returns nil.""" + with autoreleasepool(): + image = NSImage.alloc().initWithContentsOfFile("/no/file/here") + + self.assertIsNone(image) def test_objcinstance_dealloc(self): + class DeallocTester(NSObject): + did_dealloc = False + attr0 = objc_property() attr1 = objc_property(weak=True) @objc_method def dealloc(self): - self._did_dealloc = True + DeallocTester.did_dealloc = True obj = DeallocTester.alloc().init() - obj.__dict__["_did_dealloc"] = False attr0 = NSObject.alloc().init() attr1 = NSObject.alloc().init() @@ -1939,10 +2033,14 @@ def dealloc(self): self.assertEqual(attr0.retainCount(), 2) self.assertEqual(attr1.retainCount(), 1) - # ObjC object will be deallocated, can only access Python attributes afterwards. - obj.release() + # Delete the Python wrapper and ensure that the Objective-C object is + # deallocated after ``autorelease`` on garbage collection. This will also + # trigger a decrement in the retain count of attr0. + with autoreleasepool(): + del obj + gc.collect() - self.assertTrue(obj._did_dealloc, "custom dealloc did not run") + self.assertTrue(DeallocTester.did_dealloc, "custom dealloc did not run") self.assertEqual( attr0.retainCount(), 1, "strong property value was not released" ) @@ -1962,70 +2060,6 @@ def test_partial_with_override(self): obj.method(2) self.assertEqual(obj.baseIntField, 2) - def test_stale_instance_cache(self): - """Instances returned by the ObjCInstance cache are checked for staleness (#249)""" - # Wrap 2 classes with different method lists - Example = ObjCClass("Example") - Thing = ObjCClass("Thing") - - # Create objects of 2 different types. - old = Example.alloc().init() - thing = Thing.alloc().init() - - # Deliberately poison the ObjCInstance Cache, making the memory address - # for thing point at the "old" example. This matches what happens when a - # memory address is re-allocated by the Objective-C runtime - ObjCInstance._cached_objects[thing.ptr.value] = old - - # Obtain a fresh address-based wrapper for the same Thing instance. - new_thing = Thing(thing.ptr.value) - self.assertEqual(new_thing.objc_class, Thing) - self.assertIsInstance(new_thing, ObjCInstance) - - try: - # Try to access an method known to exist on Thing - new_thing.computeSize(NSSize(37, 42)) - except AttributeError: - # If a stale wrapper is returned, new_example will be of type Example, - # so the expected method won't exist, causing an AttributeError. - self.fail("Stale wrapper returned") - - def test_stale_instance_cache_implicit(self): - """Implicit instances returned by the ObjCInstance cache are checked for staleness (#249)""" - # Wrap 2 classes with different method lists - Example = ObjCClass("Example") - Thing = ObjCClass("Thing") - - # Create objects of 2 different types. - old = Example.alloc().init() - example = Example.alloc().init() - thing = Thing.alloc().init() - - # Store the reference to Thing on example. - example.thing = thing - - # Deliberately poison the ObjCInstance cache, making the memory address - # for thing point at the "old" example. This matches what happens when a memory - # address is re-allocated by the Objective-C runtime. - ObjCInstance._cached_objects[thing.ptr.value] = old - - # When accessing a property that returns an ObjC id, we don't have - # type information, so the metaclass at time of construction is ObjCInstance, - # rather than an instance of ObjCClass. - # - # Access the thing property to return the generic instance wrapper - new_thing = example.thing - self.assertEqual(new_thing.objc_class, Thing) - self.assertIsInstance(new_thing, ObjCInstance) - - try: - # Try to access an method known to exist on Thing - new_thing.computeSize(NSSize(37, 42)) - except AttributeError: - # If a stale wrapper is returned, new_example will be of type Example, - # so the expected method won't exist, causing an AttributeError. - self.fail("Stale wrapper returned") - def test_compatible_class_name_change(self): """If the class name changes in a compatible way, the wrapper isn't recreated (#257)""" Example = ObjCClass("Example") @@ -2181,3 +2215,12 @@ def work(): thread.start() work() thread.join() + + def test_get_method_family(self): + self.assertEqual(get_method_family("mutableCopy"), "mutableCopy") + self.assertEqual(get_method_family("mutableCopy:"), "mutableCopy") + self.assertEqual(get_method_family("_mutableCopy:"), "mutableCopy") + self.assertEqual(get_method_family("_mutableCopy:with:"), "mutableCopy") + self.assertEqual(get_method_family("_mutableCopyWith:"), "mutableCopy") + self.assertEqual(get_method_family("_mutableCopy_with:"), "mutableCopy") + self.assertEqual(get_method_family("_mutableCopying:"), "")