Foundation Collections and Modern Syntax: Bringing It Together
Synthesize everything learned about NSArray, NSDictionary, and NSSet through the lens of modern Objective-C syntax — literals, subscripting, and boxed expressions — and understand the deeper mechanics of collection copying, NSNull, and the immutable/mutable design that underlies Foundation's collection classes.
You've now spent several lessons learning NSArray, NSMutableArray, NSDictionary, NSMutableDictionary, NSSet, and NSMutableSet one at a time, largely through their traditional method-call syntax — arrayWithObjects:, objectForKey:, setObject:forKey:, and so on. That syntax still works today and you'll see it in older codebases, but modern Objective-C added a layer of lightweight, compiler-supported syntax on top of it — array and dictionary literals, object subscripting, and boxed expressions — that makes collection-heavy code dramatically shorter and closer to how you'd write the equivalent in Swift, Python, or JavaScript. This lesson doesn't introduce new collection types; instead it pulls together what you already know and reframes it around this modern syntax, then goes deeper into four topics that matter once you're actually shipping code with these collections: why nil can never go directly into a collection and what NSNull solves, what actually happens — and doesn't happen — when you copy a collection, and why choosing between an immutable and a mutable collection type is a real architectural decision rather than a stylistic one.
Literals as syntactic sugar, not new machinery
It's worth being explicit about something that's easy to lose sight of: @[@"A", @"B"] is not a different kind of array from one built with [NSArray arrayWithObjects:@"A", @"B", nil] — it produces the exact same NSArray class, with the exact same behavior, the exact same immutability, and the exact same memory layout. The compiler simply recognizes the @[...] syntax and rewrites it, at compile time, into a call to +arrayWithObjects:count: (the literal form actually uses a count-based variant rather than the nil-terminated one, which is part of why a literal array is allowed to contain nil-like placeholder objects such as NSNull but never a literal nil itself — more on that shortly).
The same is true of dictionary literals: @{@"name": @"Alex", @"age": @30} desugars to a call to +dictionaryWithObjects:forKeys:count:, building an NSDictionary with exactly the same runtime type you'd get from the verbose form. Nothing about the resulting object remembers that it was built with literal syntax — if you log its class with NSStringFromClass([user class]), you'll see __NSDictionaryI or __NSSingleEntryDictionaryI (Foundation's private concrete subclasses), the same classes a manually constructed dictionary would report.
This matters practically for two reasons. First, it means everything you already learned about NSArray and NSDictionary — their O(1) key lookup, their copy-on-assignment conventions, their thread-safety characteristics — applies unchanged to literal-built collections; the literal is purely a typing convenience. Second, it means you can freely mix styles in one codebase (using @[...] for a fixed literal array and arrayWithArray: for one built from another array) without creating any kind of type mismatch or compatibility problem, because underneath it's always the same NSArray.
Object subscripting: what object[key] actually calls
The line NSString *name = user[@"name"]; looks like array indexing borrowed from C, but user is an NSDictionary, not a C array, and @"name" is a string, not an integer — so something else has to be happening. That something is object subscripting, a compiler feature that translates subscript syntax into ordinary Objective-C method calls on any class that implements the right methods. For dictionary-style (keyed) subscripting, obj[key] is rewritten by the compiler into [obj objectForKeyedSubscript:key]. NSDictionary implements objectForKeyedSubscript: to do exactly what objectForKey: does, so user[@"name"] and [user objectForKey:@"name"] are two spellings of the identical call.
Array-style (indexed) subscripting works the same way but with a different pair of methods: items[0] desugars to [items objectAtIndexedSubscript:0], which NSArray implements identically to objectAtIndex:. The compiler picks which pair of methods to generate based purely on whether the subscript expression is an NSString-compatible object (keyed) or an integer (indexed) — it doesn't actually know or care whether the receiver is an NSArray or NSDictionary at compile time, which is why subscripting also works transparently on your own custom classes if you implement these methods yourself.
Mutable subscript assignment follows the identical pattern in reverse. Writing mutableDict[@"name"] = @"Jordan"; is rewritten to [mutableDict setObject:@"Jordan" forKeyedSubscript:@"name"], and mutableArray[2] = @"X"; becomes [mutableArray replaceObjectAtIndex:2 withObject:@"X"] via setObject:atIndexedSubscript:. Critically, this only compiles against a mutable collection type (NSMutableDictionary or NSMutableArray) — NSDictionary and NSArray don't implement the setter side of the subscripting protocol at all, so attempting to assign into an immutable collection through subscript syntax is a compile-time error, not a runtime crash. This is one of the few places the type system directly enforces the immutable/mutable distinction for you.
Boxed expressions: @() is not just for literals
Beginners often learn @30 as "the way you put a number literal in a collection" and stop there, but @() — boxed expression syntax — is more general than that. @(expression) will box the result of any C scalar expression, not just a literal constant: @(x + y), @(someIntVariable), @(someFunction()), and @([array count]) are all valid and each produces the appropriate NSNumber (or NSString, for boxed C strings and enum values with the right setup) at runtime by evaluating the expression first and then boxing whatever value comes out.
This distinction matters because @30 by itself is really just a convenient spelling for @(30) — the compiler treats a literal constant as the trivial case of a general expression. Once you internalize that, boxed expressions stop looking like special-cased literal syntax and start looking like what they are: a general bridge from arbitrary C scalar values (ints, floats, doubles, BOOLs, enums, and even structs like CGPoint or NSRange when NSValue supports them) into the object world that Foundation's collections require. This is exactly what lets you write @[@(width), @(height), @(someEnumValue)] to build an array containing the results of three separate runtime computations, with no manual [NSNumber numberWithInt:...] calls anywhere in sight.
Collection enumeration, revisited
You've already seen fast enumeration (for (id obj in collection)) and block-based enumeration (enumerateObjectsUsingBlock:) in earlier lessons on the individual collection types, so this section is a brief unification rather than new material. What's worth restating in the context of modern syntax is that enumeration syntax is completely orthogonal to literal syntax — a literal-built array is enumerated with exactly the same for-in loop as one built with arrayWithObjects:, and there is no special "literal collection" enumeration form.
The one enumeration-adjacent wrinkle that does interact with modern syntax is that dictionaries built with literals enumerate their keys, not their values, in a plain for-in loop — for (id key in user) gives you @"name" and @"age", and you then subscript back in with user[key] to get the value, which is exactly the pattern you'd use for a dictionary built any other way. Nothing about literal construction changes this.
NSNull: a real object standing in for nothing
Foundation's collections — NSArray, NSDictionary, NSSet, and their mutable counterparts — are built on the invariant that every slot holds a real object; internally they're backed by storage that treats nil as "end of arguments" or "nothing here," so inserting an actual nil into a collection either crashes immediately or silently truncates the collection, depending on the method. Try [NSMutableArray array] then addObject:nil and you'll get an immediate exception; try to build @[a, nil, b] as a literal and it won't even compile-time-obviously-work as intended, because nil terminates argument evaluation. This isn't a bug or an oversight — it's a direct consequence of how Objective-C collections are implemented in terms of C arrays of object pointers, where a nil pointer can't be distinguished from "array ends here."
But real-world data legitimately needs to represent "this key exists and its value is explicitly absent," which is a different concept from "this key doesn't exist at all." JSON's null is the canonical example: when NSJSONSerialization parses {"middleName": null}, it needs to put something in the resulting dictionary for the key "middleName" so that dictionary[@"middleName"] returns a real object (meaning "the key is present, value is null") rather than nil (meaning "the key isn't in the dictionary at all"). Foundation's answer is NSNull, a trivial singleton class whose entire job is to be a real, valid object you can put in a collection to mean "the absence of a value," retrievable and testable with [NSNull null].
In practice this means that whenever you pull a value out of a dictionary that may have come from parsed JSON or any other source that distinguishes "missing" from "null," you should check for both nil (key genuinely absent) and [NSNull null] (key present, value explicitly null) before using the result, typically with something like if (value == nil || value == (id)[NSNull null]) { /* treat as absent */ }. Forgetting the NSNull check is a very common source of "random crash when parsing this one field from the server" bugs, because code that assumes any non-nil dictionary value is safe to use directly will happily try to call a string method on an NSNull instance and crash with an unrecognized-selector exception.
Collection copying: shallow by design
Every Foundation collection responds to both copy and mutableCopy, and the distinction is exactly what you'd expect: copy on an NSArray (or NSDictionary, or NSSet) returns an immutable copy, while mutableCopy always returns a mutable copy (NSMutableArray, NSMutableDictionary, or NSMutableSet) regardless of what the receiver was. Calling copy on an already-immutable NSArray is actually optimized by Foundation to just retain and return the same instance rather than allocating a genuinely new one, since an immutable object can never change out from under you — there's no observable difference between sharing it and duplicating it. Calling mutableCopy, by contrast, always allocates fresh, independent storage, because the whole point is that you intend to modify the result without touching the original.
The critical thing to understand — and the thing that catches people off guard — is that this copying is shallow. Copying an array or dictionary duplicates the container: the list of pointers, the hash table, the bookkeeping structures. It does not duplicate the objects those pointers point to. If you have NSMutableArray *original containing three NSMutableString instances and you call NSArray *snapshot = [original copy], you get a genuinely separate array object — adding or removing elements from original afterward will not affect snapshot's count or contents. But snapshot's three elements are the very same NSMutableString instances as original's three elements, just referenced from a second container. Mutate one of those strings in place (via appendString:, for example) and the change is visible through both original and snapshot, because there's really only one string object, shared by two containers.
This shallow-copy behavior is exactly why immutable collections are considered "safe to hand out" only when their elements are themselves immutable or effectively treated as immutable by convention. A copy of an array of NSString gives you real protection, because NSString instances can't be mutated after creation — there's no method that could let a caller reach through a shared NSString and change what you see. A copy of an array of NSMutableString or of custom mutable model objects gives you much weaker protection: the container is independent, but the caller can still reach in, grab an element, and mutate it, and that mutation is visible to every holder of every copy of the container. Deep copying — recursively copying every contained object too — is possible in Foundation via the NSCopying protocol applied recursively, or via the specialized copyItems: family, but it is never the default, and you should never assume copy gives you one.
Immutable versus mutable as an architectural choice
It's tempting to think of NSArray versus NSMutableArray as purely a convenience decision — "use the mutable one if I'll need to add things later" — but the choice carries real architectural weight once code is being shared across methods, objects, and threads. Returning an NSMutableArray from a getter method, for instance, silently hands every caller a live handle into your object's internal state: any caller can call addObject: or removeAllObjects on what they received, and your object's internal array changes without your object's own code ever running. This is a well-known Objective-C API design pitfall, and the conventional fix is for getters to declare and return the immutable NSArray type even when the backing storage is actually an NSMutableArray internally — the caller sees a type that offers no mutation methods at all, so the compiler itself prevents the accidental (or malicious) external mutation, even though a determined caller could still defeat this with an explicit cast, since Objective-C's type system is not airtight at runtime.
The same reasoning drives the common defensive-copying pattern in initializers and setters: -initWithItems:(NSArray *)items typically does self->_items = [items copy]; rather than self->_items = items;, precisely so that if the caller passes in an NSMutableArray and continues mutating it after the call returns, your object's internal state doesn't silently change underneath it. Because copy on an immutable array is nearly free (just a retain, as covered above) but genuinely protective when the argument turns out to be mutable, defensive copying costs almost nothing in the common case and prevents an entire category of hard-to-reproduce bugs in the uncommon one.
Thread-safety is the other place this distinction bites hardest. An immutable NSArray or NSDictionary, once fully constructed, can be read from multiple threads simultaneously with zero synchronization, because nothing about reading it can race with anything else reading it — there is no mutation to race against. A mutable collection offers no such guarantee: NSMutableArray and NSMutableDictionary are not thread-safe for concurrent mutation, and even one thread mutating while another thread merely reads can crash or corrupt state. This is a major reason production codebases prefer to build up a mutable collection privately on one thread, then "freeze" it into an immutable copy (via copy) before publishing it to other parts of the app that might read it concurrently — the mutable-then-copy pattern gives you the ergonomics of mutation during construction and the safety of immutability during shared use.
Code Examples
This is the spec's canonical example. @[@"A", @"B"] compiles to an immutable NSArray via +arrayWithObjects:count:. The @{...} literal builds an immutable NSDictionary the same way, pairing each key on the left of the colon with the value on the right — note that both @"name" and @30 must be objects, so the integer 30 has to be boxed with @30 (shorthand for @(30)) since NSDictionary cannot store a raw C int. Finally, user[@"name"] is object subscripting: the compiler rewrites it to [user objectForKeyedSubscript:@"name"], which returns the NSString @"Alex". Expected result: items has 2 elements, first is A user's name is Alex, age is 30
NSString *name = user[@"name"]; — desugars at compile time to [user objectForKeyedSubscript:@"name"]; identical behavior to objectForKey:, just terser syntax.
Each @(...) here boxes the result of evaluating a real C expression at runtime, not a compile-time constant: @(x + y) evaluates the addition first and boxes the resulting NSNumber holding 17; @(x > y) evaluates the C boolean expression and boxes it as an NSNumber wrapping a BOOL/char. This demonstrates that boxed-expression syntax is a general bridge from any C scalar expression into an NSNumber (or related boxed type), not just a shorthand for typing numeric literals.
@(x + y) — the expression inside the parentheses is evaluated in ordinary C first, and only the resulting scalar value is boxed into an NSNumber.
Common mistake: The final line's use of [computed count] to build itself illustrates a real hazard: never reference a collection being constructed as part of its own literal — by the time that element is being computed, the array literal isn't finished being built yet, and computed may not yet be a valid, fully-formed object, leading to undefined behavior. This line is included only to make the general-expression point about @(); don't write self-referential literals in real code.
The dictionary stands in for a value parsed from JSON where middleName was explicitly null: [NSNull null] is a real object, so it satisfies the collection's no-nil-slots requirement, and code reading the value checks for both nil (key absent) and NSNull (key present, value null) before treating it as real data. The second half copies a mutable array holding a mutable string; the copy is a genuinely separate array (adding to original wouldn't touch snapshot's count), but because copy is shallow, the single NSMutableString element is shared — mutating it through original is visible through snapshot too. Expected result: No middle name on file snapshot still sees: draft v2
NSArray *snapshot = [original copy]; — copies the container only; the NSMutableString element inside is shared by reference, not duplicated.
NSArray *values = @[a, nil, b];
NSArray *values = @[a, hasValue ? b : [NSNull null]];
A literal collection cannot contain a literal nil among its elements — nil terminates the underlying variadic construction and produces either a compile-time-suspicious construct or a runtime crash/truncation depending on context. If a slot may legitimately have no value, use [NSNull null] as an explicit placeholder object instead of trying to put nil directly into the collection.
NSMutableArray *shared; - (NSMutableArray *)items { return _items; } // returns the live mutable array - (NSArray *)items { return [_items copy]; } // or declare the property/return type as NSArray *
Returning your object's internal NSMutableArray directly hands every caller a live reference that can mutate your internal state without your object's knowledge. Returning an immutable NSArray (either by copying or simply typing the getter's return as NSArray *) prevents external code from mutating state it shouldn't own.
NSArray *deepBackup = [original copy]; // assuming this deep-copies every contained object
// Explicitly deep-copy each element if independence from the originals is required, e.g. via NSCopying on each element or a dedicated deep-copy helper copy on a Foundation collection is always shallow: it duplicates the container, not the contained objects. Assuming a copied array's elements are independent from the original array's elements is a common source of bugs where a 'backup' or 'snapshot' collection turns out to still be affected by later mutation of the shared elements.
What does the compiler rewrite user[@"name"] into when user is an NSDictionary? A. [user objectAtIndex:@"name"] B. [user objectForKeyedSubscript:@"name"] C. [user valueForKey:@"name"] D. A direct pointer dereference with no method call
B. [user objectForKeyedSubscript:@"name"] — Keyed subscripting (obj[key] where key is an object, not an integer) is rewritten by the compiler into a call to objectForKeyedSubscript:, which NSDictionary implements with the same behavior as objectForKey:.
Why can't you put a literal nil directly into an array literal like @[a, nil, b]? A. Objective-C syntax forbids the word nil inside square brackets B. nil signals 'end of arguments' to the underlying construction, so it can't be distinguished from a real element slot C. Array literals can only ever hold exactly two elements D. nil is only disallowed in dictionary literals, not array literals
B. nil signals 'end of arguments' to the underlying construction, so it can't be distinguished from a real element slot — Foundation collections are backed by storage where nil conventionally means 'nothing here' or 'stop.' An actual nil in a collection slot is indistinguishable from that sentinel, so Foundation forbids it; NSNull exists specifically to be a real, non-nil object standing in for 'no value.'
What does [someArray copy] actually duplicate? A. Nothing; it just returns the same array object always B. Only the container itself — the pointers/bookkeeping structure — while the contained objects continue to be shared C. The container and a full recursive deep copy of every contained object D. Only the first and last elements, as a lightweight snapshot
B. Only the container itself — the pointers/bookkeeping structure — while the contained objects continue to be shared — Collection copying in Foundation is shallow: copy (or mutableCopy) produces a new container, but the objects referenced inside it are the same shared instances as in the original. Mutating a mutable element reached through the copy is visible through the original too.
What is @(x + y) an example of, where x and y are C int variables? A. A syntax error, since @() only accepts literal constants B. A boxed expression, which evaluates the C expression at runtime and wraps the resulting scalar value in an object such as NSNumber C. A block literal that lazily computes x + y D. A macro that must be defined elsewhere in the file
B. A boxed expression, which evaluates the C expression at runtime and wraps the resulting scalar value in an object such as NSNumber — @(expression) is boxed-expression syntax, and it works with any C scalar expression, not just literal constants — the expression is evaluated first in plain C, and the resulting value is then boxed into the appropriate object type, typically NSNumber.
Why does a getter that internally uses NSMutableArray commonly declare its return type as NSArray * rather than NSMutableArray *? A. Because NSMutableArray cannot be returned from a method under any circumstances B. To prevent external callers from mutating the object's internal state directly through the returned reference C. Because NSArray uses less memory than NSMutableArray at runtime D. It has no real effect; it is purely a stylistic preference with no safety implication
B. To prevent external callers from mutating the object's internal state directly through the returned reference — Declaring the return type as the immutable NSArray means the compiler won't let callers invoke mutating methods on the returned reference, protecting the object's internal state from accidental external modification, even though the underlying instance may still actually be an NSMutableArray.
NSArray *items = @[@"A", @"B"];
NSDictionary *user = @{
@"name": @"Alex",
@"age": @30
};
NSString *name = user[@"name"];
NSLog(@"items has %lu elements, first is %@", (unsigned long)items.count, items[0]);
NSLog(@"user's name is %@, age is %@", name, user[@"age"]);int x = 5;
int y = 12;
NSInteger itemCount = 3;
NSArray *computed = @[
@(x + y),
@(itemCount * 2),
@(x > y),
@([computed count])
];
NSLog(@"sum boxed: %@", computed[0]);
NSLog(@"doubled count boxed: %@", computed[1]);
NSLog(@"comparison boxed: %@", computed[2]);NSDictionary *parsed = @{
@"username": @"jsmith",
@"middleName": [NSNull null]
};
id middle = parsed[@"middleName"];
if (middle == nil || middle == (id)[NSNull null]) {
NSLog(@"No middle name on file");
} else {
NSLog(@"Middle name: %@", middle);
}
NSMutableArray *original = [NSMutableArray arrayWithObject:[NSMutableString stringWithString:@"draft"]];
NSArray *snapshot = [original copy];
[(NSMutableString *)original[0] appendString:@" v2"];
NSLog(@"snapshot still sees: %@", snapshot[0]);