Ivars and Automatic Object Equality

October, 2023

This is the penultimate post in my series on the Objective-C runtime; you can read the others here.

In earlier articles on the runtime I’ve mainly talked about properties, since they’re how we typically interact with objects. However sometimes it’s useful to pull back the curtain and inspect the instance variables (ivars) that those getters and setters hide.

For this article I’m going to show how we can inspect the ivars of an object and dynamically read and write the values of those ivars. I’ll also go over a real scenario where direct ivar access was useful for fast automatic equality checks and hashing of objects.

Backing structs

As mentioned in Dictionary-Backed Objects the actual storage for an Objective-C object is provided by a C struct.

For example, take a class representing a car:

@interface Car: NSObject
@property NSString *make;
@property NSString *model;
@property uint16 year;
@property uint8 topSpeed;
@property uint16 range;
@property BOOL isElectric;
@end

The backing struct generated by the Objective-C compiler looks something like this:

struct {
    Class isa;  // added to the start of every class
    uint8 _topSpeed;
    BOOL _isElectric;
    uint16 _year;
    uint16 _range;
    NSString *_make;
    NSString *_model;
};

But why has the order of the fields been changed? Let’s look at how this C struct is laid out in memory:

      0     1     2     3     4     5     6     7   
   ┣━━━━━┻━━━━━┻━━━━━┻━━━━━┻━━━━━┻━━━━━┻━━━━━┻━━━━━┫
0  ┃ isa                                           ┃
   ┣━━━━━┳━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━┫
8  ┃ (a) ┃ (b) ┃ _year     ┃ _range    ┃ × × × × × ┃
   ┣━━━━━┻━━━━━┻━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━━━━┫
16 ┃ _make                                         ┃
   ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
24 ┃ _model                                        ┃
   ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
   
   (a) = _topSpeed
   (b) = _isElectric
    ×  = padding

The three pointers (isa, _make and _model) are 8 bytes each, and must be aligned to the nearest 8 bytes. If the ivars had been laid out in the same order as the properties were defined, there would be more padding needed to align the pointers. The compiler is smart enough to re-order the smaller fields together at the top of the struct, minimising wasted space.

Inspecting ivars

The technique for listing the ivars of a class is very similar to that for inspecting properties. There is a function for looking up a single ivar by name (class_getInstanceVariable) and one for getting all the ivars on a class (class_copyIvarList).

NSLog(@"Ivars of class %@", self);

uint ivarCount = 0;
Ivar *ivars = class_copyIvarList(self, &ivarCount);
for (int i = 0; i < ivarCount; i++) {
    Ivar ivar = ivars[i];

    // do something with ivar
}
free(ivars);

NSLog(@"Class size: %zu", class_getInstanceSize(self));

So what details can we get on each ivar? Mainly just the name, byte offset within the backing struct, and the type encoding. From the type encoding we can also use the NSGetSizeAndAlignment function to determine how many bytes are needed to store each field.

Adding this code into the loop...

    NSUInteger size = 0;
    NSGetSizeAndAlignment(ivar_getTypeEncoding(ivar), &size, NULL);

    NSLog(@"Offset: %td\tSize: %u\t%s (%s)",
          ivar_getOffset(ivar),
          (uint)size,
          ivar_getName(ivar),
          ivar_getTypeEncoding(ivar));

...will print this when we run it:

Ivars of class Car
Offset: 8     Size: 1    _topSpeed (C)
Offset: 9     Size: 1    _isElectric (c)
Offset: 10    Size: 2    _year (S)
Offset: 12    Size: 2    _range (S)
Offset: 16    Size: 8    _make (@"NSString")
Offset: 24    Size: 8    _model (@"NSString")
Class size: 32

We can notice several things here:

  • The isa field never shows up as an ivar but the first ivar is offset by 8 bytes to account for it.
  • There is a gap of two bytes after the _range field as we saw on the memory layout diagram above.
  • The size of the class instance (32 bytes) is indeed equal to the offset plus size of the last field (24 + 8).

Note that ivar_getTypeEncoding always returns NULL for Swift classes, even when they are declared as @objc.

Other ivar functions

The remaining functions for ivars are object_getIvar and object_setIvar which read and write the value of a given ivar. These functions take id parameters for the value, so only work for properties that point to objects. For scalars we need to calculate the address of the ivar and cast it to the correct type instead.

There are two more functions called class_getIvarLayout and class_getWeakIvarLayout that indicate which ivars are object pointers and are mainly for internal use by the runtime. You can decode the value that these return using source code from the runtime itself.

Using ivars for automated equality checks

If we wanted to compare two Car objects, we’d normally have to write a method like this:

- (BOOL)isEqualToCar:(Car *)other {
    return [self.make isEqualToString:other.make]
        && [self.model isEqualToString:other.model]
        && self.year == other.year
        && self.topSpeed == other.topSpeed
        && self.range == other.range
        && self.isElectric == other.isElectric;
}

This is not only repetitive and dull to write, it’s also error-prone since we can easily forget to compare any new properties we might add to the class. You also need a matching hash function, which has the same problems.

Note that equality is a surprisingly complex topic once you start dealing with subclasses. Should an instance of a subclass ever be equal to an instance of the superclass? If so does it maintain symmetry, i.e. if [a isEqual:b] does [b isEqual:a]? It’s not something you can apply a general solution to.

Equality using properties

An app I work on had dozens of large classes which needed to support equality checking, causing constant issues with the large boilerplate isEqual and hash methods. My first solution was to add a category on NSObject to automatically calculate these using all the properties on an object, so that these methods could be just a single line for each class:

- (BOOL)isEqualToCar:(Car *)other {
    // iterates over all properties and compares their values
    return [self isEqualUsingAllProps:other];
}

It helped us to remove a lot of fragile code. However once we started using it to frequently compare view models we saw it was noticeably slower than the hand-rolled isEqual methods. The problem was using Key-Value Coding to read the value of each property, which is not as fast as comparing two property values directly.

Equality using ivars

So I wondered what if we bypass KVC and read the underlying ivars directly? Let’s give it a go:

- (BOOL)isEqualToUsingAllIvars:(id)other {
    if (self == other) {
        // identical pointers
        return YES;
    }

    if ([other isMemberOfClass:self.class] == NO) {
        // not the same class, also excludes subclasses
        return NO;
    }

    // Compare all ivars
    uint ivarCount = 0;
    Ivar *ivars = class_copyIvarList(self.class, &ivarCount);
    for (int i = 0; i < ivarCount; i++) {
        Ivar ivar = ivars[i];
        const char *encoding = ivar_getTypeEncoding(ivar);
        TypeEncoding *type = [[TypeEncoding alloc] initWithEncoding:@(encoding)];

I’ll use the TypeEncoding helper from an earlier article on Type Encodings. If the ivar is an object, we have to compare the two values using their own isEqual method:

        if (type.isObjectType) {
            id obj1 = object_getIvar(self, ivar);
            id obj2 = object_getIvar(other, ivar);
            if ([obj1 isEqual:obj2] == NO) {
                return NO;
            }
        }

Otherwise we need to compare the ivars as scalar values. A straightforward way to deal with scalar values of any size is to determine the size using NSGetSizeAndAlignment then comparing that many bytes directly. We need to do a little pointer manipulation to get the exact address of each ivar and pass those to memcmp:

        else if (type.isIntType || type.isFloatType) {
            ptrdiff_t offset = ivar_getOffset(ivar);

            // get the memory address of each ivar
            void *ptr1 = (__bridge void *)self + offset;
            void *ptr2 = (__bridge void *)other + offset;

            NSUInteger size;
            NSGetSizeAndAlignment(encoding, &size, NULL);

            // note this returns 0 if the bytes are equal
            if (memcmp(ptr1, ptr2, size) != 0) {
                return NO;
            }
        }
    }
    free(ivars);

    return YES;
}

An automated hash method looks very similar: combining the hash of object ivars, and using the private CFHashBytes function for hashing scalars.

Performance results

Using ivars turned out to be faster than comparing properties as expected, but calls to the runtime functions were still showing up in profiling. To avoid having to look up the ivar encoding and offset each time, I created a list of all the ivar details of a class that contained:

  1. The size of the ivar
  2. The offset from the previous ivar (padding)
  3. The general kind of the ivar: object reference, weak reference, scalar, or “skip” (excluded from equality checks)

This list was then compacted by replacing skipped values with extra padding on the next ivar, and combining contiguous scalars into a single one where possible. Combining scalars meant that we could compare a bigger block of memory with memcmp instead of calling it for each ivar. This encoding was calculated the first time it was needed, then cached on the class object.

So how did it perform?

It exceeded my expectations by being even faster than hand-written equality methods! The automated isEqual methods were twice as fast as the old methods, and hash was 4-5x faster.

This is still used for hundreds of classes in our app, but they’re steadily being replaced by Swift so one day I’ll sadly have to remove it. It makes me wonder though, how would this compare to the compiler-generated == and hash(into:) functions of a Swift class? 🤔


Any comments or questions about this post? ✉️ nick @ this domain.

— Nick Randall

Tagged with: