Strings in Java — practice with a student importer, a username generator and a contact finder (and why you must normalize before comparing)
I wrote a palindrome checker and, when I tested it with "ana", it worked first time. I could already picture myself handing it in. Then I tried "Ana" and it failed; and with "Anita lava la tina", it failed too. What was happening was that I’d forgotten to convert the text to lowercase and remove the spaces before comparing. Ever since, when I work with texts, I always do the same thing: first I normalize, then I compare. That idea shows up in all three programs in this practice, and you’ll see it matters more than it seems.
If you’ve already read the Strings theory, this article is for applying it with three complete programs: a student importer from CSV lines, a username and email generator, and a contact finder. All of them are medium difficulty and work with data that looks like the real thing: with extra spaces, accents, empty fields and badly formed lines. Each one comes with commented code, its output and the doubts that usually come up.
Table of Contents
Before you start
You should be comfortable with:
- That
Strings are immutable:trim,toLowerCaseand friends return a new one and you have to store it. - That they’re compared with
equals, not==. - How
substringworks (the end isn’t included),indexOf(returns-1if it finds nothing) andsplit(returns an array).
If any of them sounds shaky, go over the theory before moving on.
The programs have accents and ñ in the code. If you see odd characters when copying them, check that your editor saves files as UTF-8 (which is the default in IntelliJ and VS Code).
Program 1: student importer from CSV
We’re going to process lines of text in the format name,email,subject,grade, like the ones a spreadsheet might export. The data arrives dirty: there are extra spaces, lines missing a field, emails that aren’t from the university and invalid grades. The program has to separate what’s valid from what’s wrong, explain the reason for each error and calculate the average of the valid grades.
The idea
Each line is split with split(",") and cleaned with trim. Then the checks are done in a specific order: first the number of fields (if some are missing, we can’t look at the rest), then the email and finally the grade. Since we don’t know in advance how many lines will be valid, we use the pattern from the arrays practice: oversized arrays and a counter (valid) that says how many positions are in use.
Before programming, think about what has to be checked for a grade to be valid: that it only has digits and at most one dot, that it isn’t empty and that it doesn’t go above 10. That’s solved with a for over charAt, as in the theory.
The code
public class StudentImporter {
// Splits a CSV line and cleans the spaces of each field
static String[] splitLine(String line) {
String[] fields = line.split(",", -1); // -1: keeps the empty trailing fields
for (int i = 0; i < fields.length; i++) {
fields[i] = fields[i].trim(); // trim returns a NEW String: we store it
}
return fields;
}
// A valid grade: only digits and at most one dot, and between 0 and 10
static boolean isGrade(String s) {
int digits = 0;
int dots = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '.') {
dots++;
} else if (c >= '0' && c <= '9') {
digits++;
} else {
return false; // any other character makes it invalid
}
}
if (digits == 0 || dots > 1) {
return false;
}
return Double.parseDouble(s) <= 10; // we already know it can be converted
}
static boolean isUniversityEmail(String email) {
int at = email.indexOf('@'); // -1 if there is no @
return at > 0 && email.endsWith("@alu.ulpgc.es");
}
public static void main(String[] args) {
String[] lines = {
"Ana Pérez Suárez, ana.perez@alu.ulpgc.es, fp2, 8.5",
" Luis Gómez ,luis.gomez@alu.ulpgc.es,FP2,6",
"Marta Ruiz,marta.ruiz@alu.ulpgc.es,fp2",
"Pablo Sosa,pablo.sosa@gmail.com,fp2,7.25",
"Elena Díaz,elena.diaz@alu.ulpgc.es,fp2,9x",
"Sara Vega,sara.vega@alu.ulpgc.es,fp2,",
"Iván Mora,ivan.mora@alu.ulpgc.es,fp2,10"
};
// Parallel arrays with room for every line; 'valid' says how many positions are in use
String[] names = new String[lines.length];
double[] grades = new double[lines.length];
int valid = 0;
System.out.println("Lines with errors:");
for (int i = 0; i < lines.length; i++) {
String[] fields = splitLine(lines[i]);
String reason = null; // null = we haven't found any problem yet
if (fields.length != 4) {
reason = "expected 4 fields but found " + fields.length;
} else if (!isUniversityEmail(fields[1])) {
reason = "not a university email (" + fields[1] + ")";
} else if (!isGrade(fields[3])) {
reason = "invalid grade (" + fields[3] + ")";
}
if (reason != null) {
System.out.println(" Line " + (i + 1) + ": " + reason);
} else {
names[valid] = fields[0];
grades[valid] = Double.parseDouble(fields[3]);
valid++;
}
}
System.out.println("\nValid students:");
double sum = 0;
for (int i = 0; i < valid; i++) {
System.out.printf(" %-18s %5.2f%n", names[i], grades[i]);
sum += grades[i];
}
if (valid > 0) {
System.out.printf("Average: %.2f%n", sum / valid);
}
}
}
Output
Lines with errors: Line 3: expected 4 fields but found 3 Line 4: not a university email (pablo.sosa@gmail.com) Line 5: invalid grade (9x) Line 6: invalid grade () Valid students: Ana Pérez Suárez 8.50 Luis Gómez 6.00 Iván Mora 10.00 Average: 8.17
Check it by hand: (8.5 + 6 + 10) / 3 = 8.1666… ✔. And notice that line 2 (with spaces all over the place and the subject in capitals) is valid: trim cleaned it before anything was checked.
Decimals come out with a dot because printf uses the format of your language; on a Spanish system you’d see 8,50. In the CSV, on the other hand, decimals use a dot (8.5), because that’s what Double.parseDouble understands.
Questions you might be asking
Why split(",", -1) and not split(",")? Because of Sara Vega’s line, which ends with a comma (...,fp2,). With split(","), the empty field at the end disappears and the line would look like it has only 3 fields, with a misleading error message. With -1 it’s kept, there are 4 fields and the correct error is “invalid grade ()”. It’s detail 3 of split in the theory.
Why is fields.length checked first? Because the following checks access fields[1] and fields[3]. If the line had only 3 fields, fields[3] would throw an ArrayIndexOutOfBoundsException. The order of the checks isn’t casual: each one protects the next.
Why does isGrade loop through the text by hand instead of calling Double.parseDouble directly? Because Double.parseDouble("9x") throws a NumberFormatException and we haven’t yet seen how to handle exceptions. We validate before converting, so we never call it with a text that can’t be converted.
Why can you write c >= '0' && c <= '9'? Because every char has a numeric code and the digits come one after another ('0' up to '9'). A character between those two is a digit. It’s the classic way to check it without libraries.
Why reason = null at the start? It’s a way of using the variable as a “flag”: as long as it’s still null, we haven’t found any problem; as soon as a check fails, it’s filled with the explanation. That way, afterwards, a single question (reason != null) decides whether the line is valid.
Writing fields[i].trim(); without storing the result. The program compiles and gives no error, but the fields keep their spaces and checks such as endsWith start failing mysteriously. Always: fields[i] = fields[i].trim();.
Try it yourself
- Also check that the subject is
FP1,FP2orIC2, ignoring case, and add the corresponding error. - Also show how many lines were valid and how many were wrong.
Program 2: username and email generator
Now, the reverse path: from a student’s full name, generate their username and email. The rule is the initial of the first name plus the first surname, in lowercase and without accents: "Ana Pérez Suárez" → aperez@alu.ulpgc.es. If the username already exists, a number is added (aperez2, aperez3…). And the data comes with extra spaces, capital letters and accents anywhere.
The idea
This is where my anecdote comes in full force. Before doing anything with a name, you have to normalize it: remove spaces at the ends, convert to lowercase and remove accents and ñ. Without that, "Álvaro" and "alvaro" would be different things, and so would "PÉREZ" and "Pérez". With the text normalized, the rest is easy: split on spaces (skipping the empty ones), take the first letter of the first name and glue the surname to it.
To avoid repeats, we store the usernames already generated in an array (again with a counter) and use a loop that tries suffixes until it finds a free one.
There’s an important order inside normalize: first lowercase, then remove accents. That way you only need to handle á, é, í, ó, ú, ü, ñ in lowercase and there’s no need to repeat the work for the capitals.
The code
public class UsernameGenerator {
// Lowercases and strips accents and ñ (lowercase first: that way we only handle lowercase letters)
static String normalize(String s) {
String r = s.trim().toLowerCase();
r = r.replace('á', 'a');
r = r.replace('é', 'e');
r = r.replace('í', 'i');
r = r.replace('ó', 'o');
r = r.replace('ú', 'u');
r = r.replace('ü', 'u');
r = r.replace('ñ', 'n');
return r;
}
// First initial + first surname: "Ana Pérez Suárez" -> "aperez". Returns null if data is missing.
static String baseUsername(String fullName) {
String[] words = normalize(fullName).split(" ");
String firstName = null;
String surname = null;
for (String word : words) {
if (word.isEmpty()) {
continue; // double spaces: we skip the empty Strings
}
if (firstName == null) {
firstName = word;
} else if (surname == null) {
surname = word;
}
}
if (firstName == null || surname == null) {
return null;
}
return firstName.substring(0, 1) + surname;
}
static boolean exists(String[] used, int count, String username) {
for (int i = 0; i < count; i++) {
if (used[i].equals(username)) { // equals, not ==
return true;
}
}
return false;
}
// If "aperez" already exists, try "aperez2", "aperez3"...
static String uniqueUsername(String base, String[] used, int count) {
String candidate = base;
int number = 2;
while (exists(used, count, candidate)) {
candidate = base + number;
number++;
}
return candidate;
}
public static void main(String[] args) {
String[] names = {
"Ana Pérez Suárez",
"Álvaro Pérez Gil",
" luis gómez vega ",
"María Núñez Ortega",
"Ángela PÉREZ Díaz",
"Pedro"
};
String[] used = new String[names.length];
int count = 0;
for (String name : names) {
String base = baseUsername(name);
if (base == null) {
System.out.println("[" + name.trim() + "] -> incomplete name, no username generated");
continue;
}
String username = uniqueUsername(base, used, count);
used[count] = username;
count++;
System.out.println("[" + name.trim() + "] -> " + username + "@alu.ulpgc.es");
}
}
}
Output
[Ana Pérez Suárez] -> aperez@alu.ulpgc.es [Álvaro Pérez Gil] -> aperez2@alu.ulpgc.es [luis gómez vega] -> lgomez@alu.ulpgc.es [María Núñez Ortega] -> mnunez@alu.ulpgc.es [Ángela PÉREZ Díaz] -> aperez3@alu.ulpgc.es [Pedro] -> incomplete name, no username generated
Look at the awkward cases: " luis gómez vega " (spaces all over the place) gives lgomez; "Ángela PÉREZ Díaz" (capitals and an accent) still ends up as aperez and, since it already existed, becomes aperez3; and "Pedro" has no surname, so the program detects it instead of failing.
Questions you might be asking
Why does baseUsername return null instead of some text? Because null is the clear signal for “I couldn’t generate anything”. Whoever calls it can check it (base == null) and decide what to do. If it returned, say, an empty text, it would be easier to mistake it for a valid result. (Notice that here we do compare with ==: with null you always use ==, not equals.)
What does continue do? It jumps straight to the next pass of the loop, without running what’s below it. Here we use it to ignore the empty Strings that split(" ") leaves when there are double spaces.
Why does exists receive count? For the same reason as the upTo in the arrays practice: used has room for all the names, but only the first count positions are filled in. The others are null, and calling .equals on a null would throw a NullPointerException. That’s why we only go through what’s already stored.
How does the loop in uniqueUsername work? It starts with the candidate aperez. While it exists, it tries aperez2, then aperez3, and so on until it finds a free one. Since exists is called again with the new candidate on each pass, it always ends on a name that isn’t in use.
Isn’t there a shorter way to remove accents? Yes: the java.text.Normalizer class does it in a few lines, but it uses concepts we haven’t seen yet. The chain of replace calls is longer, but it can be understood at a glance.
Converting to lowercase after removing accents. With a capital "Á", replace('á', 'a') doesn’t find it (they’re different characters) and the accent stays. That’s why toLowerCase() goes first.
The “big array + counter” pattern (used and count) is the same one we used in the importer. It’s the way to build collections of unknown size until ArrayList arrives.
Try it yourself
- Write
isPalindrome(String text): usenormalizeand also remove the spaces (replace(" ", "")) before comparing the text with its reverse. Try it with"ana","Ana"and"Anita lava la tina"(you already know why). - Make the program generate, besides the username, an initial password with the first three letters of the surname in capitals and the last two characters of the first name.
Program 3: contact finder
The last program is an address book: each contact is a line in the format name;phone;email. We want to search by name ignoring case and accents (so searching "nunez" finds "María Núñez"), show the phone number in a readable format (612 34 56 78) and partially hide the email (a***@alu.ulpgc.es).
The idea
Normalization again, but now with a twist: you have to normalize both sides of the comparison, the user’s query and the contact’s name. If you normalized only one, searching "nunez" wouldn’t find "Núñez". After that, contains does the work.
The phone format is a substring exercise with exact indexes, and the hidden email combines indexOf (to know where the @ is) with substring (to keep the initial and everything from the @ onwards).
The code
public class ContactFinder {
static String normalize(String s) {
String r = s.trim().toLowerCase();
r = r.replace('á', 'a');
r = r.replace('é', 'e');
r = r.replace('í', 'i');
r = r.replace('ó', 'o');
r = r.replace('ú', 'u');
r = r.replace('ü', 'u');
r = r.replace('ñ', 'n');
return r;
}
// "612345678" -> "612 34 56 78"
static String formatPhone(String p) {
return p.substring(0, 3) + " " + p.substring(3, 5) + " " + p.substring(5, 7) + " " + p.substring(7);
}
// "ana.perez@alu.ulpgc.es" -> "a***@alu.ulpgc.es"
static String hideEmail(String email) {
int at = email.indexOf('@');
if (at < 1) {
return email; // no @ (or @ at the start): we leave it as it is
}
return email.substring(0, 1) + "***" + email.substring(at);
}
// Looks for contacts whose name contains the query (ignoring case and accents)
static void search(String[] contacts, String query) {
String q = normalize(query);
System.out.println("Search: \"" + query + "\"");
int found = 0;
for (String contact : contacts) {
String[] fields = contact.split(";"); // name;phone;email
if (normalize(fields[0]).contains(q)) {
System.out.println(" " + fields[0] + " | " + formatPhone(fields[1]) + " | " + hideEmail(fields[2]));
found++;
}
}
if (found == 0) {
System.out.println(" No results");
}
}
public static void main(String[] args) {
String[] contacts = {
"Ana Pérez Suárez;612345678;ana.perez@alu.ulpgc.es",
"Álvaro Pérez Gil;623456789;alvaro.perez@alu.ulpgc.es",
"María Núñez Ortega;634567890;maria.nunez@gmail.com",
"Luis Gómez Vega;645678901;luis.gomez@alu.ulpgc.es"
};
search(contacts, "perez");
search(contacts, " PÉREZ ");
search(contacts, "nunez");
search(contacts, "gómez");
search(contacts, "zzz");
}
}
Output
Search: "perez" Ana Pérez Suárez | 612 34 56 78 | a***@alu.ulpgc.es Álvaro Pérez Gil | 623 45 67 89 | a***@alu.ulpgc.es Search: " PÉREZ " Ana Pérez Suárez | 612 34 56 78 | a***@alu.ulpgc.es Álvaro Pérez Gil | 623 45 67 89 | a***@alu.ulpgc.es Search: "nunez" María Núñez Ortega | 634 56 78 90 | m***@gmail.com Search: "gómez" Luis Gómez Vega | 645 67 89 01 | l***@alu.ulpgc.es Search: "zzz" No results
Notice that the first two searches ("perez" and " PÉREZ ", with spaces, capitals and an accent) give exactly the same result. That’s what normalize achieves, and it’s the lesson the palindrome checker taught me.
Questions you might be asking
Why is fields[0] normalized and also query? Because the comparison needs both sides to be in the same “form”. If you normalize only the query ("nunez") but compare it with "María Núñez Ortega" without normalizing, contains finds nothing, because the name has an ñ and the query an n.
Why does split(";") work without escaping anything? Because the semicolon has no special meaning in a regular expression. With | or . you would have to escape it ("\\|", "\\."), as you saw in the theory.
Why are the indexes in formatPhone 0-3, 3-5, 5-7 and 7? The phone number has 9 digits and we cut it into 3 + 2 + 2 + 2: from position 0 to 3 (not including 3), from 3 to 5, from 5 to 7 and from 7 to the end. Remember that the end of substring is not included.
What happens if a phone number has fewer than 9 digits? substring would go past the end of the text and throw a StringIndexOutOfBoundsException. The method assumes correct data; in a real program you’d validate the length first with p.length() == 9.
Why does hideEmail check at < 1? Because indexOf returns -1 if there’s no @, and substring(at) with -1 would fail. With < 1 we also cover the case of an @ at position 0 (an email with nothing before it, which would have no “initial” to show).
Normalizing only one of the two sides of the comparison. It’s a very subtle bug because some searches work (the ones without accents) and others don’t, and it took me a while to spot it with the palindrome checker. If you compare texts, normalize both.
When a text method returns another text (like normalize, formatPhone or hideEmail), it’s best if it doesn’t print anything: it only returns. That way you can combine them and test them separately, as search does.
Try it yourself
- Make
searchalso find by phone: if the query is only digits, it should search the phone instead of the name. - Show the name in “Surnames, First name” format (hint:
indexOf(' ')andsubstring; for this exercise assume the first name is a single word and the rest are surnames).
Typical mistakes in this practice
- Not storing the result of
trim,toLowerCaseorreplace.Strings don’t change. - Comparing without normalizing, or normalizing only one of the two sides.
- Forgetting that
splitdiscards trailing empty pieces (use-1if they matter) and leaves empty ones in the middle with double spaces. - Accessing
fields[i]without checking how many there are:ArrayIndexOutOfBoundsException. - Using
substringorcharAtwith anindexOfthat returned-1. - Using
==instead ofequalsto compare texts (withnullyou do use==).
Summary
- Before comparing texts, normalize:
trim,toLowerCaseand, if needed, strip accents. And do it on both sides of the comparison. Strings don’t change: methods return a new one and you have to store it.- With real data, validate in order: each check protects the next (first how many fields there are, then their content).
split(",", -1)keeps the empty trailing fields; double spaces leave empty Strings in the middle.indexOfreturns-1if it finds nothing: check before using its result insubstring.- The “big array + counter” pattern is for storing results whose number you don’t know in advance.
Checklist: have you practised it?
- Have you run the three programs and checked the outputs?
- Can you explain why
isGradevalidates before callingDouble.parseDouble? - Do you know why
toLowerCasegoes first innormalize? - Do you understand why both the query and the name must be normalized?
- Do you know when to use
split(",", -1)instead ofsplit(",")? - Have you tried the “Try it yourself” parts without peeking at the theory?
If you answered yes to almost everything, you’re ready for the Strings exercises, where I no longer give you the code ready-made. See you there!
Next step: exercisesStrings in Java — exercises with palindromes, word counting and the Caesar cipher (and an == trap I didn’t see coming)Did this article help you? Here is how you can support SergioLearns →
