Skip to main content

Java Arrays of Generics - Set[] and HashSet[] Explained

Β· 14 min read
Mahmut Salman
Software Developer

When you see Set<Integer>[] rowSets = new HashSet[9];, your brain might freeze. What's the array? What are the elements? Why HashSet without <Integer>? And wait - if arrays like int[] are type-safe, why do we even need generics? Let's untangle this confusing syntax and understand why Java works this way.

The Initial Confusion​

You're implementing a Sudoku solver and encounter this syntax:

Set<Integer>[] rowSets = new HashSet[9];
Set<Integer>[] columnSets = new HashSet[9];
Set<Integer>[][] subgridSets = new HashSet[3][3];

Immediate questions flood your mind:

  • πŸ€” What exactly is the array here?
  • πŸ€” What are the elements of this array?
  • πŸ€” Why new HashSet[9] and not new HashSet<Integer>[9]?
  • πŸ€” Why not just use new HashSet<>()?
  • πŸ€” If int[] is type-safe, why do we need generics at all?

Let's answer these questions one by one, building from simple to complex.


Part 1: Building Up From Simple to Complex​

Level 1: Basic Array (Your Comfort Zone)​

int[] numbers = new int[9];

Breaking it down:

  • Left side (int[]): "This variable holds an array of integers"
  • Right side (new int[9]): "Create 9 integer slots"
  • Each element: An int (primitive, starts at 0)

Visual representation:

numbers = [0, 0, 0, 0, 0, 0, 0, 0, 0]
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
int int int int int int int int int

βœ… This is clear! The array is numbers, each element is an int.


Level 2: Array of Objects (Getting Tricky)​

String[] names = new String[9];

Breaking it down:

  • Left side (String[]): "This variable holds an array of String references"
  • Right side (new String[9]): "Create 9 slots that can hold String references"
  • Each element: A String reference (starts as null)

Visual representation:

names = [null, null, null, null, null, null, null, null, null]
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
String String String String String String String String String

Important distinction:

String[] names = new String[9];  // Array created, Strings NOT created!

// Each element is null until you assign actual String objects:
names[0] = "Alice"; // Now names[0] points to a String object
names[1] = "Bob"; // Now names[1] points to a String object
// names[2] is still null

⚠️ Key insight: The array exists, but the objects don't exist yet!


Level 3: Array of Sets (The Confusing Part)​

Set<Integer>[] rowSets = new HashSet[9];

This is where it gets confusing. Let's dissect it piece by piece.

Understanding the Left Side: Set<Integer>[]​

Read this right-to-left for clarity:

Set<Integer>[]
β””β”€β”€β”¬β”€β”€β”˜ β””β”¬β”˜
β”‚ └─ This is an ARRAY
└─ Each element is a Set<Integer>

Translation: "rowSets is an array where each slot holds a Set<Integer>"

Understanding the Right Side: new HashSet[9]​

This creates 9 empty slots for Set references.

Wait... why HashSet and not Set<Integer>? (with the diamond operator)

This is where Java has a weird limitation:

// ❌ This doesn't compile:
Set<Integer>[] rowSets = new Set<Integer>[9]; // ILLEGAL!

// βœ… This works (but gives warning):
Set<Integer>[] rowSets = new HashSet[9]; // Legal!

Why? Java doesn't allow creating arrays of generic types directly due to type erasure.


Part 2: Why Not Use new HashSet<>()?​

Understanding the Difference​

You might think: "Why not new HashSet<>() like we usually do?"

Let me show you the critical difference:

new HashSet<>() - Creates ONE Object​

Set<Integer> singleSet = new HashSet<>();
// β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
// └─ Creates ONE HashSet object

This creates one HashSet:

Memory:
singleSet β†’ HashSet object {}

new HashSet[9] - Creates an ARRAY of 9 Slots​

Set<Integer>[] rowSets = new HashSet[9];
// β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
// └─ Creates an ARRAY with 9 slots

This creates an array that can hold 9 HashSet references, but doesn't create the HashSets themselves:

Memory:
rowSets β†’ [null, null, null, null, null, null, null, null, null]
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
slot0 slot1 slot2 slot3 slot4 slot5 slot6 slot7 slot8

The Two-Phase Process​

When you want an array of HashSets, you need TWO steps:

Step 1: Create the Array Structure

Set<Integer>[] rowSets = new HashSet[9];
// β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
// └─ "Give me 9 slots for HashSet references"

Result:

rowSets = [null, null, null, null, null, null, null, null, null]

Step 2: Create Each HashSet Object

for (int i = 0; i < 9; i++) {
rowSets[i] = new HashSet<>(); // ← Using () here to create each object!
// β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
// └─ Creates ONE HashSet for slot i
}

Result:

rowSets[0] β†’ HashSet{}
rowSets[1] β†’ HashSet{}
rowSets[2] β†’ HashSet{}
...
rowSets[8] β†’ HashSet{}

Part 3: Why Use <> (Diamond Operator)?​

The Three Ways to Create a HashSet​

1. Without <> - Raw Type (Old Java, Dangerous ⚠️)​

Set<Integer> single = new HashSet();  // ⚠️ Gives compiler warning!

Problem with raw types:

Set<Integer> numbers = new HashSet();  // Raw type
numbers.add(5); // OK
numbers.add("hello"); // ⚠️ Compiles! But causes runtime error later!

// Later:
Integer num = numbers.iterator().next(); // πŸ’₯ ClassCastException!

Without generics, Java can't prevent you from putting wrong types in!

2. With Full Type - Explicit (Java 5+, Verbose)​

Set<Integer> single = new HashSet<Integer>();
// β””β”€β”€β”€β”¬β”€β”€β”€β”˜
// └─ Explicitly specify type

Works perfectly, just verbose.

Set<Integer> single = new HashSet<>();
// β””β”¬β”˜
// └─ Diamond operator (empty)

Java infers the type from the left side. Clean and type-safe!

Why Diamond <> is Better​

Without <>:

Set<Integer> numbers = new HashSet();  // Raw type

numbers.add(5);
numbers.add("oops"); // ⚠️ Compiles but wrong!

With <>:

Set<Integer> numbers = new HashSet<>();  // Type-safe

numbers.add(5); // βœ… OK
numbers.add("oops"); // ❌ Compile error! Can't add String

The diamond <> gives you compile-time protection!


Part 4: The Big Question - Why Do We Need Generics?​

"But Arrays Are Already Type-Safe!"​

You're absolutely right about arrays:

int[] numbers = new int[5];
numbers[0] = 10; // βœ… OK
numbers[1] = "hello"; // ❌ Compile error! Can't put String in int[]

String[] names = new String[5];
names[0] = "Alice"; // βœ… OK
names[1] = 42; // ❌ Compile error! Can't put int in String[]

Arrays are type-safe! So why do we need generics?

The Problem: Collections Before Generics (Pre-2004)​

Before Java 5 (2004), collections had NO generics. Everything stored Object:

// This is how Java worked before generics:

List names = new ArrayList(); // Stores Object, not String!
names.add("Alice"); // OK - String is an Object
names.add("Bob"); // OK
names.add(42); // ⚠️ ALSO OK! - Integer is also an Object
names.add(new Car()); // ⚠️ ALSO OK! - Everything is an Object

// When retrieving, you got Object back:
Object obj = names.get(0); // Returns Object, not String!

// You had to cast EVERYTHING:
String name = (String) names.get(0); // Manual cast required
String name2 = (String) names.get(2); // πŸ’₯ RUNTIME ERROR! 42 is not a String!

The problem: The compiler couldn't help you. Errors only showed up at runtime!

The Solution: Generics (Java 5+)​

Generics let you tell the collection what type it should hold:

// Now you can specify the type:
List<String> names = new ArrayList<>(); // Can ONLY hold Strings

names.add("Alice"); // βœ… OK
names.add(42); // ❌ Compile error! Can't add Integer to List<String>

// No casting needed!
String name = names.get(0); // Returns String directly!

Now the compiler can help you! Just like arrays.


Part 5: "But Why Not Just Use Arrays?"​

Valid Question: Why Not Animal[]?​

You might think: "If I know the type, why not just use arrays?"

// This works perfectly:
Animal[] animals = new Animal[5];
animals[0] = new Dog();
animals[1] = new Cat();
animals[2] = "oops"; // ❌ Compile error! Arrays ARE type-safe

So why use ArrayList?

Arrays Have MAJOR Limitations​

1. Fixed Size - Can't Grow​

// Array - fixed size:
Animal[] animals = new Animal[3];
animals[0] = new Dog();
animals[1] = new Cat();
animals[2] = new Bird();
// Now what? Can't add more! Array is full!

// ArrayList - dynamic size:
List<Animal> animals = new ArrayList<>();
animals.add(new Dog());
animals.add(new Cat());
animals.add(new Bird());
animals.add(new Fish()); // βœ… No problem! Grows automatically
animals.add(new Snake());
// ... add 1000 more if you want!

2. No Built-in Methods​

// Array - you have to do everything manually:
Animal[] animals = new Animal[100];
// How many animals? Track it yourself!
// Want to remove? Write your own code!
// Want to search? Write a loop!
// Want to sort? Write your own algorithm!

// ArrayList - has useful methods:
List<Animal> animals = new ArrayList<>();
animals.size(); // Get count
animals.remove(dog); // Remove easily
animals.contains(cat); // Search easily
Collections.sort(animals);// Sort easily
animals.clear(); // Clear all

3. Can't Remove Elements Easily​

// Array - removing is a pain:
Animal[] animals = new Animal[5];
animals[0] = new Dog();
animals[1] = new Cat();
animals[2] = new Bird();

// Remove the Cat? You have to:
// 1. Shift all elements after it
// 2. Track the new size
// 3. Or leave a null hole
animals[1] = null; // Now you have a null in the middle!

// ArrayList - one method call:
List<Animal> animals = new ArrayList<>();
animals.add(new Dog());
animals.add(new Cat());
animals.remove(cat); // βœ… Done! Automatically shifts everything

Real-World Scenario: Veterinary Clinic​

// With arrays - terrible experience:
Animal[] animals = new Animal[10]; // Guess the size?
int count = 0; // Track count yourself

void addAnimal(Animal a) {
if (count >= animals.length) {
// Need to resize? Create new array, copy everything!
Animal[] newArray = new Animal[animals.length * 2];
for (int i = 0; i < animals.length; i++) {
newArray[i] = animals[i];
}
animals = newArray;
}
animals[count++] = a;
}

// With ArrayList - easy:
List<Animal> animals = new ArrayList<>();

void addAnimal(Animal a) {
animals.add(a); // Done!
}

Part 6: The Array Covariance Problem​

"Can't I Use Inheritance With Arrays?"​

Yes, you can! But there's a dangerous catch:

// Arrays support polymorphism:
Animal[] animals = new Animal[3];
animals[0] = new Dog(); // βœ… OK - Dog is an Animal
animals[1] = new Cat(); // βœ… OK - Cat is an Animal

This works great! But watch what happens:

// This compiles but is DANGEROUS:
Dog[] dogs = new Dog[3];
Animal[] animals = dogs; // βœ… Compiles! (covariance)

animals[0] = new Dog(); // βœ… OK
animals[1] = new Cat(); // βœ… Compiles... but πŸ’₯ ArrayStoreException at RUNTIME!

Why Arrays Allow This Dangerous Behavior​

Arrays are covariant, meaning Dog[] is treated as a subtype of Animal[].

class Animal {}
class Dog extends Animal {}
class Cat extends Animal {}

// Create a Dog array:
Dog[] dogs = new Dog[3];
dogs[0] = new Dog(); // βœ… OK

// Treat it as Animal array:
Animal[] animals = dogs; // βœ… Compiles! (Dangerous!)

// Try to add a Cat:
animals[0] = new Cat(); // βœ… Compiles!
// πŸ’₯ But throws ArrayStoreException at RUNTIME!

Run this code and see for yourself:

public class Test {
public static void main(String[] args) {
Dog[] dogs = new Dog[3];
Animal[] animals = dogs; // Compiles fine

System.out.println("About to add Cat to Dog array...");
animals[0] = new Cat(); // Compiles fine, crashes here!
System.out.println("This never prints");
}
}

// Output:
// About to add Cat to Dog array...
// Exception in thread "main" java.lang.ArrayStoreException: Cat

The compiler ALLOWS it, but it crashes at runtime!

How Generics Fixed This Problem​

Generics are invariant to prevent this exact problem:

// Generics are invariant:
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // ❌ COMPILE ERROR!
// Won't even compile!

Error message:

incompatible types: List<Dog> cannot be converted to List<Animal>

This is GOOD! The compiler prevents the dangerous situation.

Polymorphism Still Works Correctly​

You can still put Dog objects in a List<Animal>:

// This is perfectly fine:
List<Animal> animals = new ArrayList<>(); // List OF Animal
animals.add(new Dog()); // βœ… OK - Dog is an Animal
animals.add(new Cat()); // βœ… OK - Cat is an Animal
animals.add(new Bird()); // βœ… OK - Bird is an Animal

The difference:

  • List<Animal> = "A list that holds Animal objects (or any subclass)"
  • List<Dog> = "A list that holds ONLY Dog objects"
  • List<Dog> is NOT a subtype of List<Animal>, even though Dog is a subtype of Animal!

Part 7: Complete Visual Comparison​

Arrays vs Generics​

Arrays (Covariant - Dangerous)​

Dog[] dogs = new Dog[3];
Animal[] animals = dogs; // βœ… Compiles (covariant)

animals[0] = new Dog(); // βœ… OK
animals[1] = new Cat(); // πŸ’₯ ArrayStoreException at RUNTIME!
// The array is actually Dog[], can't hold Cat!

Problem: Compiler allows it, but crashes at runtime.

Generics (Invariant - Safe)​

List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // ❌ Won't compile! (invariant)
// Error caught at compile-time!

// Instead, declare as List<Animal> from the start:
List<Animal> animals = new ArrayList<>();
animals.add(new Dog()); // βœ… OK
animals.add(new Cat()); // βœ… OK - Safe!

Benefit: Compiler prevents the dangerous situation entirely.


Part 8: Putting It All Together​

Complete Sudoku Example​

public class SudokuSolver {
// Track which numbers are used in each row
Set<Integer>[] rowSets = new HashSet[9];

// Track which numbers are used in each column
Set<Integer>[] columnSets = new HashSet[9];

// Track which numbers are used in each 3Γ—3 subgrid
Set<Integer>[][] subgridSets = new HashSet[3][3];

public SudokuSolver() {
// Initialize all Sets
for (int i = 0; i < 9; i++) {
rowSets[i] = new HashSet<>(); // Create each HashSet
columnSets[i] = new HashSet<>();
}

for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
subgridSets[i][j] = new HashSet<>(); // Create each HashSet
}
}
}

public boolean canPlace(int row, int col, int num) {
int subgridRow = row / 3;
int subgridCol = col / 3;

// Check if num is already used
return !rowSets[row].contains(num) &&
!columnSets[col].contains(num) &&
!subgridSets[subgridRow][subgridCol].contains(num);
}

public void place(int row, int col, int num) {
int subgridRow = row / 3;
int subgridCol = col / 3;

rowSets[row].add(num);
columnSets[col].add(num);
subgridSets[subgridRow][subgridCol].add(num);
}
}

Breaking Down the Syntax One More Time​

Set<Integer>[] rowSets = new HashSet[9];

Reading step by step:

  1. Set<Integer>[] - "rowSets is an array of Set<Integer>"
  2. new HashSet[9] - "Create 9 empty slots (all null)"
  3. Missing step: Initialize each slot with new HashSet<>()

Visual memory layout:

After declaration:
rowSets β†’ [null, null, null, null, null, null, null, null, null]

After initialization:
rowSets β†’ [HashSet{}, HashSet{}, HashSet{}, ..., HashSet{}]

After usage:
rowSets[0] β†’ HashSet{1, 5, 9}
rowSets[1] β†’ HashSet{2, 6}
rowSets[2] β†’ HashSet{}
...

Summary: Answering All Your Questions​

Q1: What is the array and what are the elements?​

Set<Integer>[] rowSets = new HashSet[9];
  • The array: rowSets (holds 9 references)
  • Each element: A Set<Integer> reference (initially null)
  • After initialization: Each element points to a HashSet<Integer> object

Q2: Why new HashSet[9] and not new HashSet<>()?​

  • new HashSet<>() creates ONE HashSet object
  • new HashSet[9] creates an ARRAY of 9 slots
  • Different purposes! Array syntax uses [], object creation uses ()

Q3: Why use <> (diamond operator)?​

  • Without: Raw type, no type safety (dangerous)
  • With <Integer>: Explicit type (verbose)
  • With <>: Type inference (best - safe and concise)

Q4: Why do we need generics if arrays are type-safe?​

  • Arrays have limitations: fixed size, no methods, manual management
  • Collections are dynamic and feature-rich
  • Generics bring compile-time type safety to collections
  • Before generics, collections could hold anything (disaster!)

Q5: Can't we just use Animal[] instead of ArrayList()?​

Yes, but:

  • ❌ Fixed size
  • ❌ No built-in methods (add, remove, search, sort)
  • ❌ Manual resizing required
  • ❌ Difficult to remove elements
  • βœ… Collections solve all these problems

Q6: Do arrays allow adding wrong types with inheritance?​

Surprising answer: Yes, at compile time!

Dog[] dogs = new Dog[3];
Animal[] animals = dogs; // Compiles
animals[0] = new Cat(); // Compiles but crashes at runtime!

Generics prevent this:

List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // Won't compile!

Comparison Tables​

Syntax Comparison​

CodeWhat's the Array?What's Each Element?Initialization Needed?
int[] nums = new int[9]numsint (primitive)❌ No (defaults to 0)
String[] names = new String[9]namesString (object)βœ… Yes (starts null)
Set<Integer>[] sets = new HashSet[9]setsSet<Integer>βœ… Yes (starts null)

Arrays vs Collections​

FeatureArraysCollections
Type Safetyβœ… Built-inβœ… With generics
Error DetectionRuntimeCompile-time
SizeFixedDynamic
MethodsNoneRich API
Remove ElementsManualEasy
Covariance Problemβœ… Yes (dangerous)❌ No (safe)

Creating Objects vs Arrays​

ScenarioSyntaxCreates
Single HashSetnew HashSet<>()ONE HashSet object
Array of slotsnew HashSet[9]9 null references
Initialize slotarray[i] = new HashSet<>()One HashSet in slot i

Key Takeaways​

Essential Concepts
  1. Two-Phase Process:

    • new HashSet[9] creates the array structure (9 empty slots)
    • new HashSet<>() creates each individual HashSet object
  2. Why Generics Exist:

    • Arrays have limitations (fixed size, no methods)
    • Collections need type safety too
    • Before generics, everything was Object (disaster!)
  3. Diamond Operator <>:

    • Provides type safety
    • Java infers the type from context
    • Always use it (except in array creation)
  4. Arrays vs Collections:

    • Arrays: Type-safe but limited
    • Collections: Feature-rich and dynamic
    • Generics: Bring type safety to collections
  5. Covariance vs Invariance:

    • Arrays: Covariant (dangerous at runtime)
    • Generics: Invariant (safe at compile-time)

Final Mental Model​

Think of it like building an apartment complex:

Arrays Without Generics (Old Java)​

πŸ—οΈ Building with unmarked apartments
Anyone can move in! ⚠️

Arrays With Generics (Modern Java)​

πŸ—οΈ Building with labeled apartments
"Apartments 0-8: Set<Integer> only" βœ…

Creating the structure:

Set<Integer>[] rowSets = new HashSet[9];  // Build 9 labeled apartments

Furnishing each apartment:

for (int i = 0; i < 9; i++) {
rowSets[i] = new HashSet<>(); // Put furniture in each apartment
}

Pro tip: Modern Java code often prefers List<Set<Integer>> over Set<Integer>[] to avoid arrays-of-generics complications entirely! But understanding this syntax is crucial for reading existing code and understanding Java's type system.

Tags: #java #generics #arrays #type-safety #collections #fundamentals