I once shipped a download settings model with a retry limit that quietly started at zero. Nothing crashed, and the debugger showed a perfectly valid value. The problem was that zero was a memory default, while the product expected three retries. I had confused safe initialization with correct business configuration.
Objective-C gives newly allocated object storage predictable zeroed instance variables, but that does not remove the need for a thoughtful initializer. The runnable project at the end models a download configuration and tests object, numeric, Boolean, structure, lazy, and server-provided values.
Instance Variables Begin With Zeroed Storage
Before an initializer assigns application-specific values, a newly allocated Objective-C object’s instance-variable storage begins zeroed. Object references therefore read as nil, integer fields read as zero, Boolean fields read as false, and the scalar members of ordinary structures begin as zero.
The sample starts with a plain DownloadSettings instance and checks each category:
DownloadSettings *plain = [DownloadSettings new];
Check(plain.folderName == nil, @"object starts as nil");
Check(plain.retryLimit == 0, @"integer starts as zero");
Check(!plain.isWiFiOnly, @"Boolean starts as false");
Check(NSEqualRanges(
plain.completedRange,
NSMakeRange(0, 0)
), @"range members start as zero");
These are language and object-allocation behaviors that application code can rely on. I still avoid writing logic that depends on an object being used before its initialization contract has completed. Zeroed storage makes construction safer; it does not turn a partially initialized object into a finished one.
A Property Does Not Create Its Object Automatically
A common misunderstanding is that declaring a strong property creates an empty collection. It does not. The generated storage begins as nil until an initializer, setter, or lazy accessor assigns an object.
@property (nonatomic, strong, nullable)
NSMutableDictionary<NSString *, NSString *> *headers;
DownloadSettings *settings = [DownloadSettings new];
NSLog(@"%@", settings.headers); // nil
Messaging nil is allowed in Objective-C, which can make this mistake look harmless. Calling setObject:forKey: through a nil dictionary simply performs no work. The application then continues without the header, and the failure may appear much later as an unexpected server response.
When an empty collection is required for every valid instance, I create it in the designated initializer. When it is genuinely optional and potentially expensive, I use a lazy accessor and test that behavior explicitly.
Initializers Should Express Business Defaults
Zero and nil are storage defaults, not universal product defaults. In the download feature, the folder should be named Downloads, the retry limit should be three, and Wi-Fi-only behavior should begin enabled. The initializer states those decisions:
- (instancetype)initWithProductionDefaults {
self = [super init];
if (self) {
_folderName = @"Downloads";
_retryLimit = 3;
_WiFiOnly = YES;
}
return self;
}
I assign directly to instance variables during simple initialization because the object is still establishing its invariant. Calling an overridable setter can trigger validation, notifications, or subclass behavior before construction has finished. A project may intentionally use setters, but that should be a documented choice.
The call to [super init] and the check of the returned object are still important. An initializer can return a different object or fail. The remaining assignments belong inside the successful branch.
Lazy Initialization Uses Nil as State
The sample delays creation of the HTTP header dictionary until it is requested:
- (NSMutableDictionary<NSString *, NSString *> *)mutableHeaders {
if (_headers == nil) {
_headers = [NSMutableDictionary dictionary];
}
return _headers;
}
This works because nil has a clear meaning: the resource has not been created. The test calls the accessor, adds a value, and verifies that the same stored dictionary is returned. That is stronger evidence than printing a pointer once.
Lazy getters need more thought when several threads may access them simultaneously. Two callers can both observe nil and create separate objects. For a thread-confined model this simple form is fine; shared services should use an explicit synchronization or one-time initialization strategy.
Nil and Zero May Represent Different Business States
A scalar property cannot distinguish a field that was absent from a server response from a field explicitly set to zero. Both appear as zero unless the parser records presence elsewhere. This matters for quotas, pagination, prices, retry limits, and feature flags.
The demo uses a nullable NSNumber for a server limit:
DownloadSettings *missing = SettingsFromPayload(@{});
DownloadSettings *explicitZero =
SettingsFromPayload(@{@"serverLimit": @0});
Check(missing.serverLimit == nil, @"field is missing");
Check([explicitZero.serverLimit isEqualToNumber:@0],
@"field was explicitly zero");
Another valid design is a separate presence flag or a parser result enum. The important part is not to let the memory default erase a distinction that the product cares about.
Local Variables Follow Different Rules
The zeroed instance storage of an Objective-C object should not be generalized to automatic local variables in C and Objective-C functions. A local scalar declared without an initializer does not acquire a useful default value. Reading it before assignment is undefined behavior, and a good warning configuration will flag the mistake.
NSInteger completedCount = 0;
NSError *error = nil;
NSRange selectedRange = NSMakeRange(0, 0);
I initialize locals at the declaration when a meaningful starting state exists. This makes every control-flow path easier to review and prevents debug builds from accidentally hiding a missing assignment.
Static-duration variables are a separate case and begin zero-initialized. Mixing instance, automatic local, and static storage rules into one statement is how misleading explanations spread. It is better to name the storage being discussed.
Do Not Skip Init Because the Bytes Are Zero
Using an object returned by alloc without completing its initializer is not a shortcut I use in application code. Initializers can establish superclass state, validate input, choose a class-cluster implementation, register resources, or return nil. Zeroed ivars do not replace any of that work.
The same caution applies to calling overridable methods during initialization. A subclass’s fields may still hold their storage defaults rather than the business values its own initializer plans to assign. Keeping construction steps local and explicit makes the object invariant easier to understand.
In tests, I create objects through their supported initializers. If I need to inspect the plain zero state for education, as this command-line demo does, I make that purpose obvious and avoid treating it as the feature’s production configuration.
The Checklist I Use for New Models
- List the invariant every fully initialized instance must satisfy.
- Separate storage defaults from product defaults.
- Create required collections in the designated initializer.
- Use lazy creation only when nil has a clear meaning.
- Consider synchronization before sharing a lazy property.
- Preserve the difference between a missing field and an explicit zero.
- Initialize automatic local variables before reading them.
- Test observable values instead of private allocation details.
For the download model, that checklist leads to a clear result. A plain instance demonstrates zeroed storage. A production instance adds the folder, retry, and network-policy values the feature requires. The lazy dictionary is created only on demand, and the nullable server limit preserves response meaning.
Download and Run the Complete Project
The complete example is a Foundation command-line program for macOS. It requires Xcode Command Line Tools and uses no external dependencies, private runtime structures, network access, accounts, or credentials.
git clone https://github.com/2252408699/objc-default-initialization-demo.git
cd objc-default-initialization-demo
make run
A successful run prints nine PASS checks followed by All initialization checks passed. Run make clean to remove the generated build directory.
Complete source code: objc-default-initialization-demo on GitHub
