انتقل إلى المحتوى
أمين أبوزيد
منشورBEG-015-L08

عرض عملي: أخطاء النصوص: == والإزاحة بواحد والتجميع البطيء

  • الدرس 8 من 8
  • 11 دقيقة قراءة
  • مبتدئ
في هذا الدرس 11 موضوعات

هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.

Lesson Overview

This is the last lesson of the module, and it is a bug hunt. Three mistakes show up again and again in String code. Two of them stop the program or give a wrong answer; the third gives the right answer, slowly. We will make each one happen on purpose, read what Java tells us, and fix it.

The picture of the module still holds: a String is a printed sheet that cannot be erased. Two sheets can carry the same words yet be two different pieces of paper (bug one). The sheet has a fixed number of positions, and asking for one past the end fails (bug two). And reprinting the whole sheet after every added word is a lot of paper (bug three).

Why this matters: these are the bugs reviewers look for first in a backend pull request. Recognizing them in seconds is a real skill.

This lesson builds on Lessons 4 and 5 and on reading stack traces (BEG-013 and BEG-014).

Learning Objectives

By the end of this lesson you will be able to:

  • Show that == on Strings can be false for identical text, and fix it with equals.
  • Write a null-safe comparison by putting the known text first.
  • Explain the end-exclusive rule of substring and fix an end index that exceeds length().
  • Handle a missing indexOf result before cutting text.
  • Measure a slow += loop against a StringBuilder and see how the cost grows with size.
  • Apply the rule of thumb: equals, check bounds, StringBuilder in loops.

Written Explanation

Bug 1: == compares references, not text

== on two reference variables asks whether they point at the very same object. The Java Language Specification says that String literals (and strings produced by constant expressions) refer to a single shared instance, so two identical literals are ==. A String created at run time is a new, separate object, even when its text is the same. That is why == sometimes works in a quick test with literals and then fails with real input from a keyboard, a file, or a network message. Use equals for the text, or equalsIgnoreCase.

A related hazard is null. Calling answer.equals("yes") when answer is null throws a NullPointerException. Writing "yes".equals(answer) is safe: a String literal is never null, and equals with a null argument simply returns false.

Bug 2: the end of the sheet

substring(begin, end) accepts begin up to end, and end up to length(). Passing length() + 1 as the end throws a StringIndexOutOfBoundsException, the String sibling of the array error. Remember the two rules together:

  • end is excluded, so substring(0, s.length()) is the whole text and is allowed.
  • The largest legal end is length(), not length() - 1.

When positions come from indexOf, a result of -1 for "not found" must be tested before it is used as an end index.

Bug 3: slow concatenation

Lesson 5 explained that s += x builds a new String that copies the old text every time. In a loop with a lot of steps, the total copying grows much faster than the number of steps, so the program is correct but sluggish. A program that is "correct but slow" gets past testing with small data, which is exactly what makes this bug expensive: it is found by customers.

A short debugging routine

For all three, the same four steps work:

  1. Reproduce with the smallest input that fails.
  2. Read the exception message (index, length) and the first line of the trace that names your own code.
  3. Name the rule that was broken (reference vs. text, end excluded, copying cost).
  4. Fix and re-run the same input to confirm.

Examples

Example 1: == versus equals (simple)

java
public class Ex1 {
    public static void main(String[] args) {
        String a = "hi";
        String b = "hi";
        String c = new String("hi");
        String lo = "i";
        String d = "h" + lo;
        System.out.println(a == b);
        System.out.println(a == c);
        System.out.println(a == d);
        System.out.println(a.equals(c));
        System.out.println(a.equals(d));
        String answer = null;
        System.out.println("yes".equals(answer));
        System.out.println(answer.equals("yes"));
    }
}

Output (compiled and run with JDK 21):

text
true
false
false
true
true
false
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because "answer" is null
	at Ex1.main(Ex1.java:15)

The two literals are the same object, so the first result is true. new String("hi") and the run-time concatenation "h" + lo build separate objects with the same text, so == is false, while equals is true in both cases. The null-safe form returns false quietly, and the unsafe form throws. The exact wording of the NullPointerException message can differ between Java versions.

Fix: always equals. Lesson: a comparison that passes with literals may fail with real data.

Example 2: bounds and indexOf (practical)

java
public class Ex2 {
    static String lastN(String s, int n) {
        if (n >= s.length()) {
            return s;
        }
        return s.substring(s.length() - n);
    }

    static String firstWord(String s) {
        int sp = s.indexOf(" ");
        if (sp == -1) {
            return s;
        }
        return s.substring(0, sp);
    }

    public static void main(String[] args) {
        String s = "backend";
        System.out.println(lastN(s, 3));
        System.out.println(lastN(s, 20));
        System.out.println(firstWord("hello world"));
        System.out.println(firstWord("single"));
        System.out.println(s.substring(0, s.length()));
        System.out.println(s.substring(0, s.length() + 1));
    }
}

Output (JDK-internal trace lines left out):

text
end
backend
hello
single
backend
Exception in thread "main" java.lang.StringIndexOutOfBoundsException: Range [0, 8) out of bounds for length 7
	at Ex2.main(Ex2.java:24)

lastN guards the case where n is larger than the text, and firstWord checks for -1 before cutting. The whole-string call substring(0, s.length()) is legal. The final call fails: the message reports the requested range [0, 8) and the length 7, and the trace line points at the faulty call. (This message format is from JDK 21; other versions word it differently.)

Fix: keep end <= length(), and test indexOf for -1. Lesson: guards go before the cut, not after the crash.

Example 3: how slow is slow? (realistic)

java
public class Ex3 {
    static String slow(int n) {
        String out = "";
        for (int i = 0; i < n; i++) {
            out += "line " + i + "\n";
        }
        return out;
    }

    static String fast(int n) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) {
            sb.append("line ").append(i).append('\n');
        }
        return sb.toString();
    }

    public static void main(String[] args) {
        for (int n : new int[]{25000, 50000, 100000}) {
            long t0 = System.nanoTime();
            String a = slow(n);
            long t1 = System.nanoTime();
            String b = fast(n);
            long t2 = System.nanoTime();
            System.out.println(n + " same=" + a.equals(b) + " plus=" + (t1 - t0) / 1_000_000 + "ms builder=" + (t2 - t1) / 1_000_000 + "ms");
        }
    }
}

Output from one run on the author's machine (your timings will differ):

text
25000 same=true plus=483ms builder=6ms
50000 same=true plus=1457ms builder=3ms
100000 same=true plus=5752ms builder=3ms

The texts are identical, so the slow version is not wrong, just expensive. Doubling the number of lines made the += version take several times longer (here about three and then about four times), while the builder stayed in the low milliseconds. This is the signature of repeated copying: the cost climbs faster than the size. The numbers come from a simple timing loop on one machine, so treat them as an illustration, not a benchmark.

Fix: a StringBuilder created before the loop and converted once afterwards. Lesson: "it gave the right answer" is not the same as "it is fine".

Backend Insight

All three bugs have production versions:

  • A role check written as if (role == "admin") passes in tests where the role is a literal and fails (or, worse, succeeds only sometimes) when the role is read from a token or a database. Comparing text is a security-relevant operation, so use equals, and write the constant first so a missing value does not crash.
  • Cutting text at positions that came from user input (a file extension, a domain name, a prefix) fails on short or malformed input. Bounds checks are part of input validation, the "never trust the user" habit from BEG-011.
  • Building a large text response in a loop with += shows up as slow requests only under real load, so a quick local run will not reveal it. Learn to recognize the pattern in review rather than waiting for a profiler.

Common Mistakes

Mistake 1: Using == because it worked once

It worked because both sides were the same literal. Real data is created at run time. Use equals.

Mistake 2: Calling equals on a possibly-null variable

answer.equals("yes") crashes on null. Prefer "yes".equals(answer).

Mistake 3: Thinking the end index of substring is a position that gets included

end is excluded, and the largest legal value is length().

Mistake 4: Cutting with an unchecked indexOf

s.substring(0, s.indexOf(" ")) crashes when there is no space, because the end index is -1.

Mistake 5: Leaving slow += in a loop because the test data is small

The program is correct, so no test fails. Decide by the shape of the code (appending in a loop), not by how fast your small test runs.

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. String x = "ok"; String y = new String("ok"); What do x == y and x.equals(y) print?

أظهر الحل A1

A1. x == y prints false (two separate objects) and x.equals(y) prints true (same characters).

A2. What is wrong with s.substring(2, s.length() + 2)? What is the largest valid second argument?

أظهر الحل A2

A2. The end index is greater than length(), so it throws StringIndexOutOfBoundsException. The largest valid end is s.length().

A3. Which of the three bugs of this lesson never changes the output, only the speed?

أظهر الحل A3

A3. The slow concatenation in a loop.

Level B: Application

B1. A command loop does if (cmd == "quit") break; and the user types quit. Why might it not stop, and what is the corrected line, also accepting QUIT with stray spaces?

أظهر الحل B1

B1. == checks whether two variables point at the same object, and a String read at run time is usually a different object from the literal "quit", so the test can fail. The correct line is if (cmd.trim().equalsIgnoreCase("quit")) break;.

B2. Write static String safeSubstring(String s, int from, int to) that never throws: it clamps from to at least 0, clamps to to at most s.length(), and returns "" if the range is empty or reversed.

أظهر الحل B2

B2.

java
static String safeSubstring(String s, int from, int to) {
    int start = Math.max(from, 0);
    int end = Math.min(to, s.length());
    if (start >= end) {
        return "";
    }
    return s.substring(start, end);
}

Math.max and Math.min pull each bound into range. When the range is empty or reversed, "" is returned before substring is called.

B3. Rewrite this so it is fast:

java
String csv = "";
for (int i = 1; i <= n; i++) {
    csv += i + ";";
}
أظهر الحل B3

B3.

java
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= n; i++) {
    sb.append(i).append(';');
}
String csv = sb.toString();

Level C: Challenge

C1. With String a = "hi"; String lo = "i"; explain why a == ("h" + "i") is true but a == ("h" + lo) is false. (Hint: when is each text assembled?)

أظهر الحل C1

C1. "h" + "i" is a constant expression: the compiler can join it and treats the result as a literal, which shares one instance with "hi", so == is true. "h" + lo uses a variable, so the joining happens when the program runs, creating a new object, so == is false. (Both facts are described in the language specification, section 3.10.5.) The takeaway is not to rely on any of this and to use equals.

C2. A teammate says: "The += loop is fine, it works." You time it with 20,000 steps and then 40,000 steps. What would you expect to see in the ratio of the two times, and what would you tell your teammate?

أظهر الحل C2

C2. The time for 40,000 steps should be clearly more than double the time for 20,000 (in our runs, roughly three to four times for each doubling), because each step copies a longer text. The builder version would take roughly double or less. Tell the teammate: it is correct, but the cost grows faster than the data, so with production-sized input it will be slow; replace it with a StringBuilder.

Mini Challenge

Write a short "String bug checklist" program: a method static boolean sameAnswer(String typed, String expected) that is case-insensitive, trims the input, and is safe when typed is null. Add a method static String domainOf(String email) that returns the part after @, or "" when the text has no @. Add a method static String repeat(String s, int times) that uses a StringBuilder. Test each with a normal input, an edge input (empty text, null, a missing @, times of 0) and print the results.

Check your work: no == on text, every cut is guarded, and the repeat method has no += inside its loop.

Summary & Next Lesson

Key takeaways

  • == compares references; use equals (or equalsIgnoreCase), and put the known text first to be null-safe.
  • substring's end is excluded and can be at most length(); test indexOf for -1 before cutting.
  • += in a loop is correct but slow; build with StringBuilder and convert once.
  • Debug by reproducing, reading the message and the first trace line from your code, naming the broken rule, and re-running the same input.
  • Rule of thumb for this module: equals, check bounds, StringBuilder in loops.

Next lesson

This was the final lesson of the module. The module lab, "Student-grade manager", is the place to apply everything: arrays of names and grades, statistics, case-insensitive search, an aligned report, and a safe empty-class case.

Self-check

  1. Why can a == b be false for two Strings with identical text?
  2. How do you compare a possibly-null String with the text "yes" without a crash?
  3. What is the largest valid end index for substring on a String of length 7?
  4. What does indexOf return when the text is missing, and why can that break a later substring?
  5. Which bug gives the right output, and how would you notice it?
أظهر الإجابات
  1. == compares whether they are the same object; a String created at run time is a different object even with the same text.
  2. "yes".equals(value).
  3. 7.
  4. -1; using it as an index or end index makes substring throw.
  5. Slow concatenation; you notice it by timing with larger inputs or by spotting += inside a loop in review.

References

  • Oracle, Java Language Specification SE 21, section 3.10.5: String literals. It supports: string literals and constant-expression strings share one instance, while strings computed at run time are newly created and distinct.
  • Oracle, String class, Java SE 21 API. It supports: substring(begin, end) excludes end and throws when end exceeds the length, indexOf returns -1 when there is no match, and equals is false for a null argument.
  • Not verified from a primary page: the statement that new String("hi") always creates a separate object (the run with JDK 21 confirmed a == c is false), and the claim that the cost of += in a loop grows faster than the input size (consistent with the timings above, which come from one machine and are not a benchmark). The exception message wording shown is from JDK 21 and may differ in other versions.

التطبيق

طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.

المختبر والتمارين الاختبار

تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.

اكتب للبحث في المستويات والمراحل والوحدات والدروس.