NSString: Foundation's Immutable String Class
Master NSString comprehensively — creation, formatting, comparison, searching, replacing, substrings, encoding and Unicode behavior, immutability, and localization — as the single most important class in the Foundation framework.
Module 13 opens Foundation, the framework underneath virtually every non-trivial Objective-C program, and there is no better place to begin than NSString, because nearly every other class you will learn in this module — NSArray, NSDictionary, NSError, NSURL — eventually hands you or expects from you an NSString somewhere in its API. NSString represents an immutable sequence of Unicode characters, and while that single sentence sounds simple, the class packs in a remarkable amount of functionality: multiple ways to construct a string, format specifiers for interpolating values, several distinct notions of string comparison, substring and search operations, and a specific, sometimes surprising relationship to Unicode that every serious Objective-C developer eventually has to understand precisely. This lesson covers all of it, in the depth this class deserves.
Creating Strings: Literals, Factory Methods, and initWith Methods
The most common way to create an NSString is the string literal, written with an @ prefix directly in source code: @"SampleApp". This produces a fully-formed, compiler-created NSString instance at compile time — it is not built at runtime the way most other objects are, which is part of why string literals are so cheap to use freely throughout your code. String literals are immutable, as is every NSString, and you'll reach for this form far more often than any other.
Beyond literals, NSString offers a family of factory (class) methods, all beginning with stringWith..., that build a string at runtime from some other source: +stringWithFormat: builds a string by substituting values into a format template (covered in detail below), and +stringWithContentsOfFile:encoding:error: reads an entire file's contents from disk into a new NSString, given a file path, a text encoding, and an out-parameter for any error that occurs. Every factory method mirrors an equivalent initWith... instance method that you call on a freshly alloc-ed instance instead of through a class method — [[NSString alloc] initWithFormat:@"Hello %@", name] does exactly the same work as [NSString stringWithFormat:@"Hello %@", name]. Under ARC, both forms manage memory identically; the factory-method spelling is simply the more common style, because it reads more naturally at the call site.
The spec's own example demonstrates both a literal and a factory method together, and it is worth internalizing as a template for how these two creation styles are normally combined: a literal for a fixed piece of text, and stringWithFormat: for building a new string out of that text plus a runtime value.
Formatting: %@, %d, and Friends
stringWithFormat: (and its sibling initWithFormat:) work exactly like NSLog or C's printf, except the result is returned as a new NSString instead of being written to the console: you supply a template string containing format specifiers, followed by one argument per specifier, and each specifier is replaced with a textual representation of its corresponding argument. The specifier you will use constantly in Objective-C is %@, which formats any Objective-C object — not just NSString — by calling its -description method and substituting the resulting text; this is why %@ works equally well for an NSString, an NSNumber, an NSArray, or any custom object that overrides -description.
The remaining specifiers largely mirror C's printf family: %d (and %i) format a signed integer, %f formats a floating-point value with default precision, and %.2f formats a floating-point value rounded to exactly two decimal places — extremely common for displaying currency or measurements. %ld and %lu format long and unsigned long values respectively (important because NSInteger and NSUInteger are typically long-sized on 64-bit platforms, so casting to long/unsigned long and using %ld/%lu is the idiomatic way to format them safely across architectures). Getting the specifier right for the argument's actual type matters: mismatching them — say, passing an NSInteger to %d instead of casting it and using %ld — can produce garbage output or, in the worst case, undefined behavior on some architectures.
The Spec Example: Building a Greeting
The canonical example from the spec ties creation and formatting together in two lines: a literal assigned to name, and a stringWithFormat: call that interpolates it into a greeting using %@. This is worth studying not because the code itself is complicated, but because this exact shape — a literal string held in a variable, substituted into a larger template via %@ — is probably the single most common NSString pattern you will write in real Objective-C code, from constructing UI labels to building log messages to composing URLs.
Note that message here is a brand-new NSString object, entirely distinct from both @"SampleApp" and the literal template @"Hello %@" — stringWithFormat: always constructs and returns a new string; it never mutates any string passed into it, consistent with NSString's immutability, discussed in full later in this lesson.
Comparison: isEqualToString: Versus ==
String comparison is the single area of NSString most likely to trip up a newcomer, and it deserves to be stated as plainly as possible: == compares pointers, not string contents. if (str1 == str2) asks whether str1 and str2 refer to the exact same object in memory — and two NSStrings can easily hold identical text while being two completely distinct objects, in which case == returns NO even though the strings are, by any reasonable definition, 'equal'. The correct way to compare NSString contents is -isEqualToString:: if ([str1 isEqualToString:str2]) compares the actual character sequences of the two strings and returns YES whenever they match, regardless of whether they are the same object.
This particular bug is common enough to be treated as a rite of passage: a newcomer writes if (userInput == @"yes"), the comparison silently evaluates to NO even when userInput visibly contains the text yes, and nothing about the resulting behavior looks like a crash — it just silently takes the wrong branch. (String literals are sometimes deduplicated by the compiler, which can make == appear to 'work' by coincidence in some narrow cases — never rely on this; it is not a guarantee, and any string built at runtime, such as one returned from stringWithFormat: or read from user input, will essentially never be pointer-identical to a literal even when its contents match exactly.)
Beyond equality, -compare: gives you ordering, returning an NSComparisonResult — NSOrderedAscending, NSOrderedSame, or NSOrderedDescending — describing whether the receiver sorts before, the same as, or after the argument, which is exactly what you need for alphabetically sorting an array of strings or implementing a custom sort comparator.
Searching: rangeOfString:, containsString:, hasPrefix:, hasSuffix:
-rangeOfString: searches the receiver for a substring and returns an NSRange describing where it was found — a location/length pair — or a range whose location equals NSNotFound if the substring does not appear at all; checking range.location == NSNotFound is the idiomatic way to test for a failed search with this method. -containsString:, available since a later Foundation revision, is the more direct way to ask a yes/no question — it simply returns a BOOL for whether the substring appears anywhere in the receiver, without requiring you to unpack an NSRange and compare its location to NSNotFound yourself.
-hasPrefix: and -hasSuffix: answer a narrower but extremely common question — does this string start with, or end with, a given substring — and both return a plain BOOL. These come up constantly for tasks like checking a file's extension ([filename hasSuffix:@".png"]) or checking whether a URL uses a particular scheme ([urlString hasPrefix:@"https://"]), and are both more direct and more efficient for that specific question than reaching for rangeOfString: and checking that the match starts at location zero or ends at the string's length.
Replacing and Substrings
-stringByReplacingOccurrencesOfString:withString: scans the receiver for every occurrence of a target substring and returns a brand-new string with each occurrence swapped for the replacement text — [@"a-b-c" stringByReplacingOccurrencesOfString:@"-" withString:@" "] returns a new string, @"a b c". As with every NSString method, the receiver itself, @"a-b-c", is completely untouched; the method's name — stringBy... — is itself a strong hint of this convention, and you'll see the same stringBy... naming pattern across most of NSString's non-mutating transformation methods.
Extracting a piece of a string is done with three closely related methods. -substringToIndex: returns everything from the start of the string up to (but not including) the given index. -substringFromIndex: returns everything from the given index through the end of the string. -substringWithRange: returns exactly the characters described by an NSRange, giving you both the starting index and the length in one call — this is the one to reach for whenever you need an arbitrary slice out of the middle of a string rather than a prefix or a suffix.
Encoding, UTF-8, and Unicode: The length Gotcha
Internally, NSString stores its characters as UTF-16 — Apple's platforms have used this representation for NSString since its earliest days — but it is capable of being constructed from, or converted to, essentially any text encoding you'll encounter, most commonly UTF-8. -dataUsingEncoding:NSUTF8StringEncoding converts an NSString to a UTF-8-encoded NSData, exactly what you need when writing text to a file or sending it over a network connection; +stringWithUTF8String: goes the other direction, building an NSString from a raw, null-terminated C string that is assumed to already be UTF-8. NSUTF8StringEncoding and its many siblings (NSASCIIStringEncoding, NSUnicodeStringEncoding, and others) are declared as an NSStringEncoding enum and appear throughout Foundation's file- and network-facing string APIs.
The single most important — and most frequently mis-assumed — fact about NSString's internal representation is what its length property actually counts. length does not count 'characters' in the everyday, human sense; it counts UTF-16 code units. For the overwhelming majority of ordinary English and Western-European text this distinction never surfaces, because most such characters occupy exactly one UTF-16 code unit each, and length happens to match what a person would call the number of characters. But it breaks down for text containing characters outside the Basic Multilingual Plane — many emoji, for instance, are represented as a UTF-16 surrogate pair, meaning a single user-perceived emoji character can contribute 2 to length, and some emoji that visually look like one character (certain flag emoji, or emoji built from multiple codepoints joined with a zero-width joiner) can contribute even more. Code that assumes length always equals 'the number of characters a person sees on screen' will misbehave — miscounting a text field's character limit, mis-slicing a substring in the middle of a surrogate pair or a grapheme cluster — the moment it encounters this kind of text, and this is one of the most important gotchas to carry forward out of this lesson.
• NSString stores characters internally as UTF-16, regardless of what encoding it was created from. • length counts UTF-16 code units, not user-perceived characters — some emoji and combined characters span more than one unit. • Never assume length equals 'number of characters a person would count' when text may contain non-BMP or combined Unicode.
Immutability: Why NSString Never Changes in Place
Every method covered in this lesson so far — formatting, replacing, extracting a substring — has one thing in common worth stating explicitly: none of them modify the string they're called on. NSString is an immutable class, meaning once an instance is created, its character content can never change for the rest of that instance's lifetime. -stringByReplacingOccurrencesOfString:withString: does not edit the receiver's characters in place; it builds and returns an entirely new NSString object containing the result, leaving the original completely untouched, exactly as this lesson's replacing section noted. This is why every one of NSString's transformation methods is named with a stringBy... or similarly forward-looking prefix rather than sounding like an in-place mutation — the naming convention itself is Apple's way of signaling 'this returns something new; it does not change what you called it on'.
Immutability is a deliberate design choice with real benefits: an NSString can be safely shared and passed around between many places in a program — stored in multiple properties, handed to multiple threads, used as a dictionary key — with an absolute guarantee that no one holding a reference to it can ever surprise anyone else by silently altering its contents underneath them. The tradeoff is that any program that genuinely needs to build up or repeatedly edit a piece of text — appending pieces in a loop, for example — pays a real cost if it keeps creating brand-new NSString objects on every edit. Foundation's answer to that need is NSMutableString, a subclass of NSString that adds genuine in-place mutation methods like -appendString: and -deleteCharactersInRange:; NSMutableString is covered in its own dedicated lesson, but it is worth knowing now, while immutability is fresh, that it exists specifically to fill this gap.
Localization: NSLocalizedString
Every user-facing piece of text in a real, shipping application eventually needs to support more than one language, and Foundation's foundational tool for that is the NSLocalizedString macro. NSLocalizedString(@"Welcome", @"Greeting shown on the home screen") looks up the key @"Welcome" in the app's localization tables — .strings files, one per supported language — and returns the translated text for whatever language the current user's device is set to, falling back to the key itself if no translation is found. The macro's second argument is a comment intended for translators, describing the context the string appears in, which is invaluable once a string like @"Close" might need a different translation depending on whether it means 'close this dialog' or 'this store is closed'.
This lesson only introduces NSLocalizedString briefly, because a full treatment of localization — managing multiple .strings files, pluralization rules, locale-aware date and number formatting — belongs to its own later lesson. The point worth carrying forward from here is simply the habit: any string literal that a user will actually see on screen is a candidate for wrapping in NSLocalizedString from the very start of a project, rather than retrofitting localization onto hundreds of hardcoded literals later.
Creating and Formatting an NSString
@"..." is a compiler-created string literal, always immutable. stringWithFormat: builds and returns a brand-new NSString; it never modifies name. %@ formats any Objective-C object by calling its -description method.
NSString *name = @"SampleApp";
NSString *message = [NSString stringWithFormat:@"Hello %@", name];Code Examples
name is a compile-time string literal. message is a distinct, newly-allocated NSString built at runtime by substituting name into the template using the %@ specifier, which works for any object because it calls -description internally. Neither statement mutates any existing string. Expected result: message == @"Hello SampleApp"
NSString *message = [NSString stringWithFormat:@"Hello %@", name];
userInput and the literal @"yes" are two distinct NSString objects that happen to hold identical characters, so userInput == @"yes" evaluates to NO — the pointer comparison silently fails even though the text visibly matches. isEqualToString: compares actual contents and correctly evaluates to YES.
if ([userInput isEqualToString:@"yes"]) { Common mistake: Comparing NSString content with == instead of -isEqualToString:.
hasSuffix: and containsString: both return BOOL for quick yes/no checks. stringByReplacingOccurrencesOfString:withString: returns a brand-new string (csv) with .pdf swapped for .csv, leaving path unchanged. rangeOfString:options: finds the final slash, and substringFromIndex: extracts everything after it to isolate the filename. Expected result: filename == @"report.pdf"; csv == @"/users/sampleapp/report.csv"; path is unchanged
NSString *csv = [path stringByReplacingOccurrencesOfString:@".pdf" withString:@".csv"];
greeting.length correctly returns 2 for two ordinary ASCII characters. But withEmoji.length returns 5, not the 4 a person would count ('H', 'i', ' ', emoji) — because the emoji is represented internally as a UTF-16 surrogate pair, contributing 2 to length rather than 1. dataUsingEncoding: and stringWithUTF8String: show the standard round trip to and from UTF-8. This is exactly the gotcha to watch for: NSString's length counts UTF-16 code units, not user-perceived characters, which matters most for emoji and other characters outside the Basic Multilingual Plane.
NSLog(@"%lu", (unsigned long)withEmoji.length); // 5, not 4 -- the emoji spans 2 UTF-16 units
NSString *name = @"SampleApp";
NSString *message = [NSString stringWithFormat:@"Hello %@", name];NSString *userInput = [NSString stringWithFormat:@"%@", @"yes"];
if (userInput == @"yes") {
NSLog(@"Pointer match (unreliable!)");
}
if ([userInput isEqualToString:@"yes"]) {
NSLog(@"Content match (correct)");
}NSString *path = @"/users/sampleapp/report.pdf";
if ([path hasSuffix:@".pdf"]) {
NSLog(@"It's a PDF file.");
}
if ([path containsString:@"sampleapp"]) {
NSLog(@"Belongs to sampleapp.");
}
NSString *csv = [path stringByReplacingOccurrencesOfString:@".pdf" withString:@".csv"];
NSRange lastSlash = [path rangeOfString:@"/" options:NSBackwardsSearch];
NSString *filename = [path substringFromIndex:lastSlash.location + 1];NSString *greeting = @"Hi";
NSLog(@"%lu", (unsigned long)greeting.length); // 2
NSString *withEmoji = @"Hi \U0001F600"; // 'Hi ' plus a grinning-face emoji
NSLog(@"%lu", (unsigned long)withEmoji.length); // 5, not 4 -- the emoji spans 2 UTF-16 units
NSData *utf8Data = [greeting dataUsingEncoding:NSUTF8StringEncoding];
NSString *roundTripped = [NSString stringWithUTF8String:[greeting UTF8String]];Where NSString Correctness Actually Bites
The == versus isEqualToString: mistake and the length-versus-Unicode gotcha are not academic curiosities — they are two of the most commonly reported real-world Objective-C bugs. A login form that compares a typed password against a stored one with == can accept the wrong password or reject the right one depending on incidental string interning; a character-limit field that trusts length to mean 'characters typed' can reject perfectly valid short messages once a user types an emoji, or worse, truncate a string in the middle of a surrogate pair and corrupt it. Apps that ship internationally — which is nearly every app — run into the length gotcha constantly once real user-generated content, with emoji and combined Unicode characters, starts flowing through text fields that were only ever tested with plain English strings during development.
if (userInput == @"yes") {... } if ([userInput isEqualToString:@"yes"]) {... }
== compares object pointers, not string contents. Two NSStrings holding identical text are frequently distinct objects, so == silently returns NO even when the strings are, by content, equal. isEqualToString: is the correct comparison for content.
[myString appendString:@" more text"]; // treating NSString as if it were mutable
NSString *result = [myString stringByAppendingString:@" more text"];
NSString has no appendString: method at all — it is immutable, and every transformation method returns a brand-new string rather than modifying the receiver in place. In-place mutation requires NSMutableString, a distinct subclass covered in its own lesson.
NSUInteger charCount = someTextWithEmoji.length; // assumed to equal 'number of characters visible on screen'
// Treat length as a count of UTF-16 code units; use higher-level APIs (e.g. enumerateSubstringsInRange: with NSStringEnumerationByComposedCharacterSequences) when a true user-perceived character count is required.
length counts UTF-16 code units, not user-perceived characters. Text containing emoji or other characters outside the Basic Multilingual Plane can make length larger than the number of characters a person would actually count, silently breaking character-limit or slicing logic.
What kind of object does the spec's NSString *name = @"SampleApp"; create? A. A mutable string built at runtime B. An immutable string literal, created by the compiler C. A reference to a file on disk named SampleApp D. An NSMutableString subclass instance
B. An immutable string literal, created by the compiler — @"..." syntax produces a compiler-created NSString literal at compile time. It is immutable, like every NSString instance.
Why does if (str1 == str2) frequently give the wrong answer when comparing two NSStrings with identical text? A. == always returns NO for any two NSString objects, even the exact same instance B. == compares object pointers/identity, not string contents, so two distinct objects with identical text compare unequal C. NSString does not support the == operator at all and the code fails to compile D. == only works correctly on NSMutableString, never NSString
B. == compares object pointers/identity, not string contents, so two distinct objects with identical text compare unequal — == tests whether two references point at the exact same object in memory. Two NSStrings can hold identical characters while being two separate objects, in which case == returns NO despite the content matching — isEqualToString: is the correct tool for content comparison.
What does NSString's length property actually count? A. The number of user-perceived characters, always matching what a person would count on screen B. The number of bytes the string occupies when encoded as UTF-8 C. The number of UTF-16 code units, which can exceed the number of user-perceived characters for emoji and other non-BMP characters D. The number of words in the string, separated by whitespace
C. The number of UTF-16 code units, which can exceed the number of user-perceived characters for emoji and other non-BMP characters — NSString stores characters internally as UTF-16, and length counts UTF-16 code units. Characters outside the Basic Multilingual Plane, including many emoji, are represented as surrogate pairs, so a single user-perceived character can contribute more than 1 to length.
What does -stringByReplacingOccurrencesOfString:withString: do to the string it is called on? A. It mutates the receiver in place, replacing the matched substrings directly B. It returns a brand-new string with the replacements applied, leaving the original receiver completely unchanged C. It deletes the receiver and returns nil D. It only works on NSMutableString, never plain NSString
B. It returns a brand-new string with the replacements applied, leaving the original receiver completely unchanged — NSString is immutable, so this method — like every NSString transformation method — builds and returns a new string with the substitution applied, while the original string it was called on is left untouched.
What does the NSLocalizedString macro do? A. It converts an NSString to lowercase for case-insensitive comparison B. It looks up a key in the app's localization tables and returns the translated text for the user's current language, falling back to the key if no translation is found C. It permanently mutates a string in place to a new language D. It is only usable with NSMutableString, never plain NSString
B. It looks up a key in the app's localization tables and returns the translated text for the user's current language, falling back to the key if no translation is found — NSLocalizedString(key, comment) looks up key in the appropriate .strings localization file for the user's current language and returns the translated text, with the comment argument existing to give translators context; if no translation is found, it falls back to returning the key itself.
Given NSString *filename = @"invoice_final.PDF";, write the expressions needed to: (1) correctly check, case-sensitively, whether it ends in .PDF; (2) build a new string with the extension replaced by .txt; (3) explain why comparing filename == @"invoice_final.PDF" would be an unreliable way to check the whole filename.
(1) [filename hasSuffix:@".PDF"] — returns YES/NO by comparing the actual trailing characters. (2) NSString *renamed = [filename stringByReplacingOccurrencesOfString:@".PDF" withString:@".txt"]; — returns a new string; filename itself is untouched, since NSString is immutable. (3) == compares whether filename and the literal are the exact same object in memory, not whether their characters match; filename could easily be a distinct NSString instance (say, built at runtime from a file listing) holding identical text, in which case == would incorrectly evaluate to NO even though the content is identical.
hasSuffix: for the extension check, stringByReplacingOccurrencesOfString:withString: for the replacement, and recall what == actually compares for NSString.