Sunday, June 15, 2025

equals()

Now that we’ve created plenty of objects in Java and we’ve seen several data structures, we need to address a very large elephant in the room. I still haven’t told you how to check—easily, anyway—if two objects are “equal”, or what that means about a non-primitive, anyway.


Let’s start with the easy stuff—equality in primitives:

  • Integers are equal if their values are the same. 2 is equal to 2, but not to 327.
  • Doubles are equal if their values are the same: 3.14159 is equal to 3.14159, but not 3.15
  • And so on with shorts, bytes, longs, and floats
  • Characters are equal if they’re the same: ‘A’ is equal to ‘A’, but not ‘a’
  • Booleans are equal if they have the same value: false and false are equal, but false and true are not

Because they’re primitives, we learned we make comparisons with == (or !=, or whatever you want), since single = is used for assignment, so it can’t be lifted directly from math.

Objects are a little trickier. Equality by = still works, but there’s a catch. That checks if the memory addresses of two things are the same.  

So let’s suppose I have

String s1 = “hello”;

and


String s2 = “hello”;

 

Intuitively, there’s nothing different about s1 and s2. Both have exactly the same contents, an all-lowercase “hello” and nothing else.

But Java doesn’t see those as the same. s1 and s2 are different objects, stored in different places in memory, so their addresses are different, so s1==s2 would return false.

Now, in Java, we have a concept called aliasing.

Let’s say we do the following instead:

  • We create String s1 = “hello”; as before
  • But now, instead, we sat String s2 = s1;

Now, s2 is an alias of s1—s2 and s1 point to the same location in memory.

Suppose s2 then changes to “oi” instead. After this change, s1 points to “hello” (nothing changed), but s2 points to the brand new, different, in a different place, “oi”.

Let’s see that in action:

class Main {

    public static void main(String[] args) {

        String s1 = "hello";

// s2 points to the same place
String s2 = s1;

// hello       
System.out.println(s1);

// hello       
System.out.println(s2);

s2 = "oi";

// hello      
System.out.println(s1);

// oi       
System.out.println(s2);

    }

}

While it is still true that s1 and s2 point to the same place (before a new String is created, when s2 becomes “oi”), then s1==s2 would return true. But once the value of s2 changes, s1 and s2 no longer point to the same place, so s1==s2 would return false.  

Without aliasing, let’s consider the following scenario:

class Main {

    public static void main(String[] args) {

       

        String s1 = "hello";

        String s2 = "hello";

    }

}

In this case, is it true that s1==s2? Again, it feels like it should be true, but it’s false. By not saying s2=s1 as before, we are telling the computer that s1 and s2 are completely different objects, which we don’t intend to point to the same place in memory.

But we, wise humans who can read and know there’s more to the story, know the contents of the Strings are identical. Surely, then, there must be a way to check “even if the Strings don’t have the same address, are they equal in the sense that every character in one matches every character in the other?”

In fact, most of the time, you won’t care where things are stored in memory—after all, this isn’t C, so you aren’t allocating and freeing memory yourself, leaving that all to Java. What you’ll most likely want to know is whether the contents of a String are equal to that of another.

For that, we have the equals() method.

equals() isn’t unique to Strings, however. All objects—whether you created them, or someone else did, like String, or Random, or anything else—since they descend from the Object class (this is never stated explicitly, but it’s always true) inherit some of its methods. If you didn’t when we covered this fact when we looked at inheritance, then I suggest you look at the API documentation now. You’ll see that one of the methods in Object—thus inherited by every class, whether you use it or not—is the equals() method.

Recall the difference between overloading and overriding. Overloading is, for example, having an add() method that takes in 2 ints, another that takes in 2 doubles, another for 2 bytes, another for 2 longs, etc. Overriding, meanwhile, is changing the behavior of whatever you inherited, to better fit the needs of the child class. The default behavior is exactly what we saw earlier, with the equality (or not) of the addresses of “hi”.  

Since you’ve inherited that method from Object, you can, of course, override it to better suit your needs. String has done this already, and String’s equals() checks if the contents are equal. If every character in two Strings is the same, they are the same.

Note that, for example, “I LIKE PIZZA” is not equal to “I like pizza” or “i like pizza” or “ I LIKE PIZZA” or “I LIKE PIZZA ”. “I LIKE PIZZA” matches exactly only with “I LIKE PIZZA” and only in that instance would equals() return true.

Suppose you have a Student class and you want to check if two Students are the same. Suppose a Student has an ID, name, date of birth, year, GPA, and major. You could certainly plausibly override the equals() in Student to return true if and only if all those fields—ID, name, date of birth, year, GPA, and major—all are exactly the same. You may have "the same" Student in different places in memory, so what you care about are the stats, not the memory addresses. If they are all the same, then presumably you and your program would both conclude that the Students are the same. But if any of those properties mismatch, you’re looking at different Students.

How you define equals() is largely up to you. Just make sure that whatever criteria you pick make sense and are specific enough to get the definition of equality you want—and be aware that, if, in the course of comparing Puppy objects, you need to check if their owner Strings are the same, then leading and trailing spaces, or capitalization, all matter when detetmining equality or not. 

Saturday, June 14, 2025

Sets

 In plain English, “set” and “group” mean the same thing—but not in math or computer science. In computer science, a “set,” properly, is a collection of things where each thing is unique: {red, blue, yellow, turquoise, white, black, fuchsia}, or {1, 2, 3, 5, 8, 13, 21}—but not {P, E, N, N, S, Y, L, V, A, N, I, A}.


(As you’ll see soon, the last of these is not, in the Java sense, a “Set”.)

Java maintains Sets, and they have a number of use cases.  At this basic level, perhaps the most obvious is transforming a non-unique collection into a unique collection. That may look like, as roundabout as this is (it works):

  1. Make a List of things
  2. Look at one element at a time
  3. If the element is unique, add it to the Set
  4. Once everything is processed, make a List out of the contents of the set, and you’ll have all the original List, but no duplicates anymore

As usual, before we go further, let me put it out there that there exists a ton of great documentation on Sets, if you know where to look.

(In fact, if you look through the documentation, you’ll see that the above-described 4-step process could be just one—make a call to Set.of(), which will create a set out of whatever you pass.)

Sets are intended to behave just as their mathematical cousins would, so in addition to using of() to quickly make a Set out of something else, you should of course know that there is add() to add things one by one (if they can be added; if thery’re there already, they’ll just be ignored), or addAll() to add all contents of a Collection—more on this later, but the most common you’ve seen so far is a List; or remove (or removeAll, again, passing in a Collection); size(), which gives the count of unique elements in the set; or toArray(), which converts the Set into an array of elements of the type of the Set. Of course, just like with Lists, Queues, and Stacks, it’s quite useful in many circumstances to know if a Set is empty or not; naturally enough, the method for this (as has previously been the case) is isEmpty().

Other operations you might want to do include easily combining several sets into one, checking if a particular item is present or not, or deleting everything inside. These are all supported, and the documentation online does a great job of walking you through how to use these methods—how to call them and what to expect back.

We’ll soon get to dealing with graphs (not bar graphs and pie charts, but rather networks of dots and lines), and many of the algorithms we’ll work through on graphs—certain searching algorithms, or path-finding ones, for example—use Sets extensively.

Data structures like Lists, arrays, Maps, Queues, Stacks, and Sets—you already know 6, and soon, you’ll know many more, so go ahead and celebrate!—can be daunting at first. (I know that when I was taking Data Structures, at least in the beginning, it felt like climbing Mount Everest. But there is good news: It does get easier, and you do get better over time! Don’t worry!)

                                                                                                                          

Friday, June 13, 2025

Nulls

 Every data type has a default value. For primitives, if they’re numbers, its their form of 0. For booleans, it’s false. For the char type, it’s ASCII 0—which is not literally the character ‘0’, but a NULL value, a sort of way to say “this space contains a thing, and that thing is ‘nothing’”, as distinct from “this is an empty box.”

For objects, then, of any sort, the default value is null. “null” is reserved in Java, so just as you can’t name a variable “int” or “for” or “while,” you can’t name it “null” either.

When you create an object, before it gets a value, if you try to access it—say, to print out its contents—the value you will get on the other end will be null.

If, for example, you’re moving along a List, and you’ve reached the end of the list, then the “next” pointer won’t point to another element of the list, but will point to null, and accessing that phantom element (the one after the last one) will produce a NullPointerException.

This behavior is a core part of how Java manages memory and object references. The use of null is both practical and intentional—it signals that a reference exists, but it doesn’t point to any actual object in memory. However, this design also introduces a common pitfall: attempting to call methods or access fields on a null reference will trigger a NullPointerException. For example, if you try to call .length() on a String variable that’s null, or .next on a null node in a linked list, Java will throw this exception to alert you that your code is trying to operate on nothingness. To avoid these errors, it’s considered best practice to perform explicit null checks before accessing object members.

Java developers often encounter null in more complex scenarios, such as when working with collections or APIs. For instance, some methods may return null to indicate the absence of a value—like when searching for a key that doesn’t exist in a Map, or when a database query finds no matching row. In these cases, it’s up to the programmer to decide how to handle null values: should they throw an exception (we’ll cover this very soon), return a default value, or perhaps propagate the null up the call stack? This decision can have significant implications for the robustness and clarity of your codebase.

Look at this example:

public class NullExample {

    public static void main(String[] args) {

        String message = null; // message is declared but not set to a real string

 

        // This would throw a NullPointerException if uncommented:

        // System.out.println("Message length: " + message.length());

 

        // Safe way: check for null before using message

        if (message != null) {

            System.out.println("Message length: " + message.length());

        } else {

            System.out.println("Message is null!");

        }

    }

}

The String message is declared null, so accessing it a few lines later (that part is commented out, so it woud not execute) would return an error that, in this case, would crash the program. That is why it’s so important to take the safe approach and check whether something is null or not, and only take action on it if we know it isn’t null, as we do at the end. 

Thursday, June 12, 2025

HashMaps

 A map is a very powerful data structure that lets us relate one thing to another in pairs. One of these things is a “key,” and the other is a “value,” so we call the pairs “key-value” pairs. For now, know that one of the benefits of maps is that they have very efficient lookup. Whereas arrays or lists might require a linear search, there is some magic going on (which we will eventually cover) that allows maps to find whatever you are looking for almost instantaneously, both in the human-time sense and the computer-time sense.

As I’m sure you’ve gathered by now, I’m a huge fan of putting links to official Java documentation in these articles. Look at it here.

If you open the documentation link, you’ll immediately see how HashMaps are created: with the declaration of two types in the angle brackets, just as we had one type in angle brackets for Lists, Queues, and Stacks. Recall that, since we need both a key and a value, we need the two types this time, not just one.

Look at this example code snippet:

import java.util.HashMap;

 

public class HashMapExample {

    public static void main(String[] args) {

        // Create a HashMap

        HashMap<String, Integer> map = new HashMap<>(); // notice the pair of types

 

        // Add key-value pairs

        map.put("Apple", 3);

        map.put("Banana", 5);

        map.put("Orange", 2);

 

        // Access a value by key

        System.out.println("Quantity of Apples: " + map.get("Apple"));

 

        // Check if a key exists

        if (map.containsKey("Banana")) {

            System.out.println("Banana is in the map.");

        }

 

        // Remove a key-value pair

        map.remove("Orange");

 

        // Iterate through the HashMap

        for (String key : map.keySet()) {

            System.out.println(key + ": " + map.get(key));

        }

    }

}

First, notice the import statement and how we declare the HashMap. (The declaration is legal because every HashMap is a Map.) Second, take note of the names of the operations: the “add” operation is called “put,” and the “retrieve” operation is called “get.” To check if a particular key exists, there is a containsKey() method; for values, there is a similar containsValue(). Removing a pair—not just a key, or not just a value—is done by remove(), passing in the key. get() takes in a key and returns the associated value.

In this case, the mapping is one-directional: “Delta 105” maps to “Atlanta to Guarulhos.” Naturally, “Atlanta to Guarulhos” should map to “Delta 105,” since it does in the real world: the delta flight from ATL to GRU is 105; and Delta 105 is the flight from ATL to GRU. These kinds of maps do exist, but the HashMap is not one by default. To get bidirectional mapping, either make two HashMaps with opposite keys and values (the key of one is the value of the other) or use a BiMap. Google has an implementation in their Guava library, but, whenever I’ve wanted to do something bidirectional, I have always found it easier to write (and to understand later) the code that just uses two opposite maps the regular way.

This is how bidirectional mapping would look:
import java.util.HashMap;

import java.util.Map;

public class SimpleBiMap<K, V> {

    private final Map<K, V> forwardMap = new HashMap<>();

    private final Map<V, K> reverseMap = new HashMap<>();

 

    public void put(K key, V value) {

        if (forwardMap.containsKey(key) || reverseMap.containsKey(value)) {

            throw new IllegalArgumentException("Key or value already exists in the bimap");

        }

        forwardMap.put(key, value);

        reverseMap.put(value, key);

    }

 

    public V getValue(K key) {

        return forwardMap.get(key);

    }

 

    public K getKey(V value) {

        return reverseMap.get(value);

    }

 

    public void removeByKey(K key) {

        V value = forwardMap.remove(key);

        if (value != null) {

            reverseMap.remove(value);

        }

    }

 

    public void removeByValue(V value) {

        K key = reverseMap.remove(value);

        if (key != null) {

            forwardMap.remove(key);

        }

    }

}

 

public class AirlineRouteMap {

    public static void main(String[] args) {

        SimpleBiMap<String, String> routeMap = new SimpleBiMap<>();

 

        // Assign routes

        routeMap.put("Delta 105", "Atlanta to Sao Paulo");

        routeMap.put("United 222", "New York to London");

        routeMap.put("American 333", "Los Angeles to Tokyo");

 

        // Look up by flight code

        System.out.println("Route for Delta 105: " + routeMap.getValue("Delta 105"));

        // Output: Route for Delta 105: Atlanta to Sao Paulo

 

        // Look up by route description

        System.out.println("Flight code for 'New York to London': " + routeMap.getKey("New York to London"));

        // Output: Flight code for 'New York to London': United 222

    }

}

Here, SimpleBiMap maintains the two opposite maps. AirlineRouteMap simply implements a specific case for airline routes: given a city pair, you get the number, and given the number, you get the city pair.

Keys must be unique in a HashMap; values need not be. Because of this, if you want to get all the keys, the data structure that is returned is a Set, and the data structure of all the values is a Collection. If what you want is a Set of key-value pairs, that can come out of a HashMap as the entrySet. Map has inside of itself the class Map.Entry<K,V>, so should you want a Set of key-value pairs rather than a Map, you can get it.  

Wednesday, June 11, 2025

Using stacks to do math

 Expressions in math can be ambiguous. Every few months, something like “6/(2+1)*2” goes viral on Twitter, and half the population says “I’m a math professor and the answer is 6” and the other half says “I have 4 math degrees and the answer is 1.”

We are now equipped to solve the problem with Postfix Notation, which takes all that ambiguity out and always produces the correct answer, using a Stack.

Before we look at Postfix, let’s look at Infix. We all know Infix, it’s just very uncommon to see it called that—it’s the expression above. When we have “the sum of a and b”, then that’s written as “a + b” with “a” and “b” surrounding the “+” operator, and so on. As written, “the sum of a and b” looks like Prefix, since “the sum of” comes before the arguments “a” and “b”. Naturally, then, Postfix is where we would see something (in plain English) as “a and b added together,” where a and b come first, and only after you have enough operands do you give the operation.

In Postfix, push onto a stack, until you have enough operators and operands that an operation is legal, pop them off, do the computation, and push the result back.

Let’s take as an example 2 + (3 + 4 + 5 * (6 - 7)), which has some of the same parenthetical ambiguities as the problems that go viral. This is equivalent (just adding some parentheses) to 2 + ((3 + 4) + (5 * (6 - 7))).

In Postfix, that looks like 2 3 4 + 5 6 7 - * + +.

Let’s now use a stack, tracking every operation:

Token

Operation

Stack State

2

Push 2

[2]

3

Push 3

[2, 3]

4

Push 4

[2, 3, 4]

+

Pop 4 and 3 (the top 2 numbers, since + takes 2 arguments), push 3+4=7

[2, 7]

5

Push 5

[2, 7, 5]

6

Push 6

[2, 7, 5, 6]

7

Push 7

[2, 7, 5, 6, 7]

-

Pop 7 and 6 (the top 2 numbers, since - takes 2 arguments), push 6-7=-1

[2, 7, 5, -1]

*

Pop -1 and 5 (the top 2 numbers since * takes 2 arguments), push 5×(-1)=-5

[2, 7, -5]

+

Pop -5 and 7, push 7+(-5)=2

[2, 2]

+

Pop 2 and 2, push 2+2=4

[4]

We’ve gotten to the end of the string, and we have nothing else to process, so the final answer on the stack is the correct answer.

Let’s go back to the fully-parenthesized infix and solve it, just to be sure.

2 + ((3 + 4) + (5 * (6 - 7)))  

  1. The innermost parentheses are 6-7 = -1
  2. Multiply that by 5 = -5
  3. At the same level, we now have 2 additions: 3+4, and {that result}, plus our -5
  4. 3 + 4 is 7
  5. 7 - 5 is 2
  6. Now we’ve cleared all the parentheses, and the result of all that was 2
  7. The last step is to add 2 to the result of the parentheses, which is 4
  8. Now we’ve cleared the whole expression, so the final answer is 4

 Come up with some relatively simple expressions in traditional infix, and do 3 things:

  1. Convert to postfix
  2. Use a stack to solve the postfix expression, stepping through with a table as shown above
  3. Solve the expression in infix to validate the answer and show to yourself that the methods are equivalent

Tuesday, June 10, 2025

Java is hard. Trust me-- I know.

 Before we continue with more Java, I just wanted to take a quick break today to acknowledge how difficult programming (in Java or any language) can be and how frustrating it can be, especially at the beginning. (But, spoiler alert, the frustration doesn’t go away as you get more experienced; early problems get much easier, but harder problems can still be just as frustrating!)

You’ll write a lot of code on your way to Java proficiency—many thousands of lines. If you’re following along, learning from me, then you’re just getting started. People who do this for a full career from early adulthood to retirement will end up writing hundreds of thousands of lines over all those years.

And in all those years writing all those lines, there will be lots of bugs. If your lifetime output is 500,000 lines, you probably wrote at least 1,000,000 bugs along the way.  Brady, Manning, Montana, Brees, etc., are some of the greatest quarterbacks ever, and none of them has a career completion percentage over 70; each of them, as great as they were, made thousands of mistakes over their careers. Even the best make mistakes! That's OK! You will, but you'll improve over time! 

The more practice you get with the simple parts, the easier it is for those to be bug-free. There will be a point, as a programmer, where certain structures become so obvious that they become second nature. If you aren’t at that point yet, don’t worry! Bugs are a critical part of the learning process. (It took me an embarrassingly long time, for example, in 2017-18, to consistently remember to end my lines with a ;, and that’s a serious enough bug that your code will not run if even one of them is missing.)

Bugs come in two general flavors:

  • Errors in syntax
  • Errors in logic

Syntax errors, generally, are easier to grow out of: “oops, I called an int a ‘num’ because I forgot the name of the data type” or “oops, I left off a semicolon, or didn’t pair () inside () inside () correctly so there was one too many (”. These come down to learning the conventions of the language and being careful, just like a college professor makes fewer spelling mistakes than her 5-year-old kindergartener. The professor has been reading and writing far longer, so she has seen much more correct spelling and had many more opportunities to practice it.

Logic errors, on the other hand, are the real nasty bugs—the reason people in coding make so much: because it’s our job to work through an algorithm to do something, put it on paper, explain it to a coworker (this is great at catching these bugs), and then translate plain English into computable Java. If a problem is just particularly hard, or if your boss’s requirements aren’t clear, or if you’re working with something new, or if you’ve had a bad day, you’re much more likely to make logic errors, even after 10, 20, 30 years of coding. That is to say: professionals write bugs all the time, so if you’re just getting started, don’t beat yourself up!

But at the same time, don’t look for the easy way out. If you’re learning Hanoi, don’t Google the solution, or read too far down my article without playing with plates first. If you have an infinite loop or StackOverflowException somewhere, don’t throw your hands up and give the problem to an LLM to fix for you. It’s totally normal, especially early, to feel the pressure to get things right, right away—but this does not (or should not) exist. You will, and you can, make mistakes. You are learning!

If you're learning a new algorithm and you come across a bug, then the best things to do are any of the following:

  • Take a break
  • Ask questions 
  • Find a good explainer video/article
  • Ask someone else for help
  • Try to teach someone the algorithm and answer their questions
  • Make your learning experience multi-sensory somehow
But DON'T:
  • Go to an LLM right away for a quick and easy answer
  • Copy a solution from somewhere like StackOverflow or ChatGPT
  • Give up
  • Ask someone else to write it for you

Monday, June 9, 2025

Generic Types

Generics allow you to write code that works with any data type, making your classes and methods more reusable and type-safe. Instead of writing the same code for every type (imagine how much repetition you would need, if you had to cover every type you can come up with!), you can write it once and use it for many types.

Here’s a simple generic class that can store any type of data:

// A generic class that can store any type of data
class Box<T> {
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

public class Main {
    public static void main(String[] args) {
        Box<Integer> intBox = new Box<>(123);
        Box<String> strBox = new Box<>("Hello Generics!");

        System.out.println(intBox.getValue()); // Output: 123
        System.out.println(strBox.getValue()); // Output: Hello Generics!
    }
}



Here, T is a placeholder for the type you want to use. When you create a Box<Integer>, T becomes Integer; for Box<String>, it becomes String. 

You can also make methods generic:

public class Main {
    // Generic method
    public static <T> void printItem(T item) {
        System.out.println("Item: " + item);
    }

    public static void main(String[] args) {
        printItem(42);           // Output: Item: 42
        printItem("Java");       // Output: Item: Java
    }
}



This method works with any type you pass to it because you're passing a T, a generic. A "T" can be a CollegeStudent, a FluffyUnicorn, a PepperoniPizza, whatever you want.

Java has primitive data types like int, double, and char. However, sometimes you need to use objects instead of primitives—for example, when working with collections like ArrayList. That’s where wrapper classes come in.
What Are Wrapper Classes?

Wrapper classes “wrap” primitive values in an object. The name of the wrapper class is, capitalized, the written-out name of the data type (as in, "Integer" or "Double", etc.)


You use wrapper classes whenever you need an object instead of a primitive.

You can’t use primitives in collections like ArrayList, as we've already seen. You must use their wrapper classes:

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        // ArrayList<int> numbers = new ArrayList<int>(); // Invalid!
        ArrayList<Integer> numbers = new ArrayList<>();   // Valid!
        numbers.add(10);
        numbers.add(20);

        System.out.println(numbers); // Output: [10, 20]
    }
}



Here, Integer is used instead of int.


Autoboxing is when Java automatically converts a primitive to its wrapper class when needed. Unboxing is the opposite process by which Java automatically converts a wrapper object back to a primitive.


Integer myInt = 5;      // Autoboxing: int to Integer
int num = myInt;        // Unboxing: Integer to int

System.out.println(myInt); // Output: 5
System.out.println(num);   // Output: 5



This makes it easy to switch between primitives and objects.


Generics make your code flexible and type-safe, letting you write reusable classes and methods.


Wrapper classes let you use primitive values as objects, especially useful in collections and when object methods are needed.


Java handles most conversions between primitives and wrapper classes automatically (autoboxing/unboxing).

These concepts are foundational for modern Java programming—mastering them will make your code safer, cleaner, and more powerful

Switch

 Other than if/if-else/if-else if-else and the ternary operator, there is yet another common and important conditional expression in Java th...