Lecture from: 08.11.2024 | Video: Videos ETHZ

Enums (Enumerated Types)

Enums are special classes used to represent a fixed set of named values. They are useful when you need a type that can only hold a specific set of constants.

Motivation

Using basic types (like int or String) to represent a fixed set of values has drawbacks:

  1. Large Value Range: int and String allow values that might not be meaningful in your context (e.g., a negative order status).
  2. Invalid Operations: You can perform operations on int and String that don’t make sense for your enumerated type (e.g., adding two order statuses).

Defining Enums

public enum Status {
    SUBMITTED,
    PROCESSING,
    PACKED,
    SHIPPED,
    RECEIVED,
    PAID
}

Enum Benefits

  • Type Safety: The compiler ensures that variables of the enum type can only hold one of the defined enum constants.
  • Readability: Using named constants improves code readability.
  • Built-in Functionality: Enums provide useful methods like name(), valueOf(), and ordinal(). They also integrate well with switch statements.

Example: Order Status

public class Order {
    private Status status = Status.SUBMITTED;  // Default status
 
    // ... other methods ...
 
    public void processOrder() {
      if (this.status == Status.SUBMITTED) {
          this.status = Status.PROCESSING;
          // ... process the order ...
      } // ...handle other statuses...
    }
}
 
 
Order order = new Order();
if (order.status == Status.PROCESSING) { 
    // ...
}
 
 
String message = switch (order.status) {
    case SUBMITTED, PROCESSING -> "Order in progress";
    case SHIPPED -> "Order shipped";
    case RECEIVED, PAID -> "Order completed";
};

Extending Enums (Beyond EProg Scope)

You can add attributes, methods, and constructors to enums to extend their functionality. However, this is beyond the scope of this introductory EProg course.

We’ve covered the essential elements for working with classes and objects in Java, providing a solid foundation for object-oriented programming. Remember that encapsulation and well-defined interfaces are critical for building robust and maintainable software systems.

Code Style, Conventions, and Refactoring

This lecture focuses on improving code quality through consistent style, adherence to conventions, and refactoring. These practices are essential for writing readable, maintainable, and extensible code, especially in collaborative software development projects.

High-Quality Software: Motivation and Goals

High-quality software possesses a range of desirable characteristics, including:

  • Correctness: The software functions as intended and meets its specifications.
  • Robustness: The software handles unexpected inputs and errors gracefully.
  • Maintainability: The software is easy to understand, modify, and fix.
  • Extensibility: New features can be added easily without breaking existing functionality.
  • Portability: The software can run on different platforms or environments.
  • Testability: The software can be easily tested to ensure its correctness.
  • Scalability: The software can handle increasing workloads or data volumes.
  • Cost-Effectiveness: The software is developed and maintained efficiently.
  • Readability/Understandability: The source code is easy to read and comprehend.
  • Performance: The software executes efficiently.
  • User-Friendliness: The software is easy for users to interact with.
  • Reusability: Parts of the software can be reused in other projects.
  • Backward Compatibility: The software maintains compatibility with older versions.
  • Marketability: The software is appealing to potential users or customers.

These properties often overlap (e.g., reusability and testability) and sometimes conflict (e.g., cost-effectiveness vs. robustness). Achieving high-quality software requires a combination of technical expertise and good software engineering practices, including careful attention to code style, conventions, and refactoring.

Code Style and Conventions: Goals and Enforcement

Code style and conventions are primarily concerned with the syntactic aspects of code, such as formatting, layout, and naming. While these aspects don’t affect the program’s execution (the compiler doesn’t care), they significantly impact readability and maintainability, which are crucial for human developers.

Goals of Consistent Code Style:

  • Uniformity: Creates a consistent look and feel across the codebase.
  • Improved Readability: Makes it easier to understand code written by different people.
  • Facilitates Collaboration: Reduces friction and misunderstandings in team projects.
  • Automated Checks: Enables automated style checking and enforcement.

Style Guides and Enforcement

Code style is typically defined and enforced through project- or company-specific style guides. These guides provide a set of rules and recommendations for formatting, naming, and other stylistic aspects of the code. Examples include style guides from Sun/Oracle, OpenJDK, and Google. While conventions offer flexibility and exceptions can be made if justified, consistency is paramount.

Tools for Automatic Formatting and Style Checking

IDEs like Eclipse and other tools (linters, style checkers) can automate formatting and style checking. These tools can:

  • Apply Formatting Rules: Automatically reformat code to adhere to a specific style guide.
  • Identify Style Violations: Detect and report deviations from the style guide.
  • Integrate with Version Control: Enforce style checks as part of the code review or commit process (e.g., using Git/GitLab).

Although these tools are valuable, developers should not rely on them blindly. Sometimes, deviations from the strict rules of a style guide are justified for improved clarity or other reasons.

Naming Conventions

Consistent naming is fundamental for readable code. Java follows established naming conventions that enhance clarity and reduce ambiguity.

  • General Principle: Names should be self-explanatory but concise. While extreme brevity (single-letter variables) is sometimes acceptable (e.g., loop counters), names should generally convey the purpose or meaning of the element they represent.

Specific Conventions

  • Variables:

    • Start with a lowercase letter (e.g., currentLocation).
    • Use camel case (e.g., numberOfStudents).
    • Constants (declared final) are typically written in uppercase with underscores separating words (e.g., MAX_VALUE, PI).
    • Avoid redundant type information in names (e.g., prefer names over namesArray).
  • Methods:

    • Start with a lowercase letter (e.g., calculateArea()).
    • Use camel case (e.g., getFirstName()).
    • Typically use verbs or verb phrases to indicate the action performed by the method.
  • Classes:

    • Start with an uppercase letter (e.g., Scanner).
    • Use camel case (e.g., RandomNumberGenerator).
    • Typically use nouns or noun phrases to describe the concept represented by the class.
  • Packages: (More relevant in larger projects.)

    • Use all lowercase letters (e.g., java.util).
    • Use dots to separate levels of nesting (e.g., com.example.project).

Formatting and Layout

Consistent formatting and layout are essential for readability. Here are key aspects of good code formatting in Java:

  • Line Length: Limit line length to a reasonable maximum (typically 80, 100, or 120 characters). Long lines should be broken up for clarity. This makes it easier to read code without horizontal scrolling.

  • Indentation: Consistently indent code blocks within curly braces {}. The standard practice is to use spaces (typically 2 or 4) for indentation. Consistent indentation clearly shows the structure and nesting of the code.

  • Braces: There are different styles for placing curly braces, but consistency is key. A common practice is to always use braces even for single-statement blocks. This helps prevent errors when adding or removing code later.

  • One Statement per Line: Avoid placing multiple statements on the same line. Each statement should have its own line for readability.

  • Spacing:

    • Use spaces around operators (e.g., x = 1 + 2;).
    • Do not use spaces in method calls or array indexing (e.g., myMethod(arg1, arg2), data[i] ).
    • Use spaces before and after keywords and parentheses (e.g., for (int i = 0; i < n; i++), if (condition) { ... }).
  • Blank Lines: Use blank lines to separate logical units of code, such as methods, classes, or blocks of code within a method. This improves visual organization and makes the code easier to scan.

What is Refactoring?

Refactoring is the process of restructuring existing code without changing its external behavior. It’s like reorganizing the internal structure of a house without altering its external appearance. The goal of refactoring is to improve the internal quality of the code, making it:

  • More readable
  • Easier to understand
  • Easier to maintain
  • Easier to extend
  • Potentially more performant (although this is often a secondary goal)

Key Principles of Refactoring

  • Behavior Preservation: The refactored code must have the same external behavior as the original code. This is crucial. Refactoring is about improving the structure, not changing the functionality.
  • Small Steps: Refactoring is best done in a series of small, incremental changes. Each small change is easier to test and less likely to introduce errors.
  • Testing: Thorough testing is essential throughout the refactoring process. After each small change, test the code to ensure that its behavior has not changed.

Common Refactoring Techniques

The following examples illustrate some typical refactoring techniques in Java. These are just a few examples, and many other techniques exist.

1. Extracting Methods

If a method is too long or performs multiple distinct tasks, break it down into smaller, more focused methods. This improves readability and makes the code easier to reuse.

Example: (Simplified – often involves more complex logic)

Before:

public double getSalary() {
    double sumStd = 0;
    for (int j = 0; j < hours.length; j++) {
        sumStd += hours[j];
    }
    double sumOvt = 0; // Overtime calculation
    for (int j = 0; j < overtime.length; j++) {
        sumOvt += overtime[j];
    }
    return sumStd * 30.70 + sumOvt * 38.40; 
}

After: (Using a hypothetical sum method – perhaps in a MathUtil class)

public double getStandardSalary() {
    return sum(hours) * 30.70;
}
 
public double getOvertimeSalary() {
    return sum(overtime) * 38.40;
}
 
public double getSalary() {
  return getStandardSalary() + getOvertimeSalary();
}
 
 
public static double sum(double[] values) { // In a utility class
  double sum = 0;
  for(double val : values) {
      sum += val;
  }
  return sum;
}

2. Simplifying Conditional Logic

Complex nested if-else statements can often be simplified.

Example:

Before:

if (day < 1 || day > 31) {
    result = false;
} else if (year < 1900) {
    result = false;
} else if (month < 1 || month > 12) {
    result = false;
} else {
    result = true;
}

After: (Using De Morgan’s law and combining conditions)

result = 1 <= day && day <= 31 &&
         1 <= month && month <= 12 &&
         1900 <= year;

3. Replacing Conditional Logic with the Ternary Operator

For simple if-else structures, the ternary operator can make the code more concise.

Example:

Before:

if (numbers[i] < 0) {
    filtered[i] = 0;
} else {
    filtered[i] = numbers[i];
}

After:

filtered[i] = (numbers[i] < 0) ? 0 : numbers[i];

4. Identifying and Removing Redundant Code

Duplicate code should be eliminated by extracting it into a common method or function. This reduces code size and makes maintenance easier. (See example 1 above).

Recognizing Refactoring Opportunities

Identifying opportunities for refactoring often requires experience and a critical eye. Some indicators that code might benefit from refactoring include:

  • Long Methods: Methods that are too long or do too much.
  • Duplicated Code: The same or very similar code appearing in multiple places.
  • Complex Conditional Logic: Nested if-else structures that are hard to understand.
  • Large Classes: Classes that have too many responsibilities.
  • Poorly Named Variables or Methods: Names that are unclear or misleading.

Refactoring and Tools

Some IDEs provide tools to assist with refactoring. These tools can automate common refactoring tasks, such as extracting methods or renaming variables, while ensuring that the code’s behavior is preserved. However, the most challenging part is not executing the mechanical steps of refactoring—it’s recognizing the opportunity and the need for refactoring.

Code Conventions: Key Takeaways

  • Consistency is Key: The most important aspect of code conventions is consistency. Choose a style guide and stick to it throughout your project.
  • Team Agreement: In team projects, agree on a common style guide early on and ensure everyone follows it.
  • Tool Support: Use IDEs and automated tools to enforce style and formatting rules. But don’t rely on them blindly; sometimes, thoughtful deviations are justified.
  • Subjectivity: Remember that some aspects of style are subjective. The goal is to write clear, readable, and maintainable code, not to achieve perfect adherence to every rule in a style guide.

Refactoring: Key Takeaways

  • Small, Incremental Changes: Refactor in small steps, testing thoroughly after each change.
  • Behavior Preservation: Ensure that the refactored code behaves identically to the original code.
  • Practice and Experience: Recognizing refactoring opportunities and applying appropriate techniques effectively takes practice and experience. Regularly review your code critically and look for ways to improve its structure and clarity.
  • Tool Support: IDEs offer refactoring tools that can automate many common tasks, making the process safer and more efficient.

Software Engineering Principles

The ideas of code style and refactoring are rooted in broader software engineering principles. Here are some key concepts (some mentioned earlier):

  • Separation of Concerns: Divide your code into smaller, more manageable units, each responsible for a specific task or aspect of the functionality. This applies to both methods and classes.
  • Don’t Repeat Yourself (DRY): Avoid code duplication. Extract common code into reusable functions or methods.
  • Keep It Simple, Stupid (KISS): Strive for simplicity in your code. Avoid unnecessary complexity.
  • YAGNI (You Aren’t Gonna Need It): Don’t implement features or functionality until they are actually needed. Avoid premature optimization or over-engineering.
  • SOLID Principles: (More advanced – you’ll likely encounter these later in your studies.) A set of five design principles intended to make software designs more understandable, flexible, and maintainable.

Further Reading

For those interested in exploring these topics further, here are some highly recommended books (although they go beyond the scope of this introductory course):

  • Clean Code by Robert C. Martin
  • The Pragmatic Programmer by Andrew Hunt and David Thomas
  • Refactoring: Improving the Design of Existing Code by Martin Fowler
  • Tidy First? A Personal Exercise in Empirical Software Design by Kent Beck
  • Code Complete by Steve McConnell
  • Head First Design Patterns by Eric Freeman and Elisabeth Freeman

By applying the principles and techniques discussed in this lecture, you can write significantly better, more maintainable, and more professional code. While good style and refactoring might seem like extra effort initially, they pay off significantly in the long run by reducing development time, improving collaboration, and making your code more robust and adaptable to future changes.

Recursive Classes: Linked Lists

This lecture introduces recursive classes—classes that contain attributes of their own type.

Recursion Review

Before diving into recursive classes, let’s briefly review the concept of recursion in different contexts:

  • EBNF: In EBNF, recursion is used to define grammars. A non-terminal symbol can be defined in terms of itself.

    <number> ::= (0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9) [<number>]
    
  • Mathematics: Recursion is used to define functions or sequences. A function can be defined in terms of its value at a smaller input.

    n! = 1, if n ≤ 1
    n! = n * (n-1)!, otherwise
    
  • Java Methods: Recursive methods call themselves within their own definition.

    public static void f() {
        f(); // Recursive call
    }

Recursive Classes in Java

A recursive class is a class that contains one or more attributes (fields) whose type is the class itself. This self-referential structure allows us to create data structures like linked lists and trees.

Example:

class A {
  A a; // Attribute 'a' is of type A (the same class)
}

Linked Lists: Motivation

Arrays have a limitation: their size is fixed at creation time. Inserting an element into an array often involves creating a new, larger array and copying all the elements, which can be inefficient. Linked lists provide a more flexible alternative, allowing us to add or remove elements at any position without resizing the entire structure.

Linked Lists: Structure

A linked list consists of a sequence of nodes. Each node stores:

  1. A value (the data element).
  2. A reference (next) to the next node in the sequence.

The last node’s next reference points to null, indicating the end of the list. The list itself is represented by a reference to the first node (often called the head). This next reference is what makes the ListNode class recursive.

Java Representation:

class ListNode {
  int value;
  ListNode next; 
}

Example: (Conceptual Representation – we’ll see proper construction later)

list -> [42 | next] -> [-3 | next] -> [17 | null] 

In this example:

  • list is a reference to the first node (head) of the linked list.
  • The first node stores the value 42 and a reference to the next node.
  • The second node stores -3 and a reference to the third node.
  • The third node stores 17 and its next field is null, marking the end.

This linked list structure allows efficient insertion and deletion of elements because we only need to change a few references, without moving or copying large amounts of data.

Building Linked Lists: Node Construction and Linking

We’ll use the ListNode class from above and add constructors to facilitate creating and connecting nodes.

class ListNode {
    int value;
    ListNode next;
 
    // Constructor to create a node with a given value
    ListNode(int value) {
        this.value = value;
        this.next = null; // Initially, the next node is null
    }
 
    // Constructor to create a node with a value and a reference to the next node
    ListNode(int value, ListNode next) {
        this(value);       // Call the other constructor to set the value
        this.next = next;  // Set the next node reference
    }
}

Example: Creating a Linked List

ListNode last = new ListNode(17);      // Create the last node (value 17)
ListNode middle = new ListNode(-3, last); // Create the middle node (value -3, next is last)
ListNode list = new ListNode(42, middle); // Create the first node (value 42, next is middle)
 
 
// Or, more concisely (nested construction):
 
ListNode list = new ListNode(42, new ListNode(-3, new ListNode(17)));
 
 
// Accessing elements (for illustration—we'll create methods for this later):
System.out.println(list.value);        // Output: 42
System.out.println(list.next.value);   // Output: -3
System.out.println(list.next.next.value); // Output: 17
 

This code creates the same linked list as shown in the conceptual representation as above.

Manipulating Linked Lists: Adding Elements

1. Adding at the End

To add an element at the end of the list, we need to traverse the list to find the last node and then update its next reference.

Example: (Assume list points to the first node of a list containing 10 and 20)

list.next.next = new ListNode(30); // Add 30 at the end

2. Adding at the Beginning

Adding an element at the beginning is simpler. We create a new node and set its next reference to the current head of the list. The new node becomes the new head.

Example: (Again, assume list points to a list containing 10 and 20)

list = new ListNode(4, list); // Add 4 at the beginning

3. Inserting in the Middle (or at a specific index)

Example: Suppose you have list1: 10 20 and list2: 30 40. You want to insert the node 30 (the head of list2) after the 10 in list1.

ListNode oldHead = list2;      // Keep a reference to the 30 node
list2 = list2.next;            // Update list2 to start at 40
oldHead.next = list1.next;    // Link 30 to 20
list1.next = oldHead;          // Link 10 to 30

These examples demonstrate basic linked list manipulation.

Continue here: 15 Linked Lists, Inner Classes, Methods, Recursive Methods, Built-in Lists