23. Packages

Notes:

  • Packages in Java allow you to organize your code in a way that helps you find necessary files/classes quickly. They also assist in preventing conflicts in class names (e.g. 2 classes with the same name will not conflict if they are in separate packages).
  • The naming convention of package names is typically lowercase, with no spaces nor underscores (package names are usually concise).
  • Use the package keyword the specify the package a file is a part of, and use the import keyword to be able to implement that file from a separate file/class. The * keyword in an import statement indicates that all of the files/classes of the package will be imported.
  • Packages in Java follow a hierarchy, which means that you can have packages within packages. Slashes (/) are used to indicate/separate sub-folders, while dots (.) are used to indicate/separate sub-packages.
  • Package names should be unique in the whole world, so that code can be redistributed effectively without any conflicts. The naming convention of unique package names is first putting either your name (full name) or reversing the name (order of words) of your organization's website, then putting the sub-package name that indicates the purpose of your package.
  • In Java, libraries are similar to packages in that they are essentially collections of pre-defined modules such as classes, interfaces, and methods, which collectively provide some specific functionality. However, libraries are different from packages in that packages essentially group code modules as well as other forms or resources (including libraries) for structural purpose, meaning that packages sometimes encompass libraries and are more broad.

Examples:

// The package statement should be the first statement in the file, and defines the package that the file is a part of
package ocean;

public class Fish {
    
}
|   package ocean;
illegal start of expression
// Another class (Shark) that is a part of the ocean package, aside from the Fish class.
package ocean;

public class Shark {

}
|   package ocean;
illegal start of expression
// The plants package is a part of the ocean package.
package ocean.plants;

// The Algae class is a part of the plants sub-package of the ocean package.
public class Algae {

}
|   package ocean.plants;
illegal start of expression
// Unique package name, where the ToDoList class is a part of the personalization sub-package, whose parent package is thebusinessnexus, whole parent package is com.
// This order of package names is unique and is unlikely to conflict with other package names.
// The personalization package by itself may not be unique, but will be unique if combined with the reversed name of thebusinessnexus.com.
package com.thebusinessnexus.personalization;

public class ToDoList {

}
|   package com.thebusinessnexus.todolist;
illegal start of expression
// Import the Fish class from the ocean package, so that the Application class can implement the Fish class.
import ocean.Fish;
// Import the Shark class from the ocean package.
import ocean.Shark;
// Import the Algae class from the plants sub-package of the ocean package.
import ocean.plants.Algae;
// * indicates that every class from the ocean package is imported.
import ocean.*;
// Import the ToDoList class from the personalization sub-package of the thebusinessnexus sub-package of the com package (thebusinessnexus and com make up the actual website name).
import com.thebusinessnexus.personalization.ToDoList;

public class Application {
    public static void main(String[] args) {
        Fish fish = new Fish();
        Shark shark = new Shark();
        Algae algae = new Algae();
        ToDoList toDoList = new ToDoList();
    }
}

Application.main(null);
|   import ocean.Fish;
package ocean does not exist

24. Interfaces

Notes:

  • In Java, interfaces are implicitly abstract (you do not need to declare them with the abstract keyword), and the methods within them are in turn implicitly public and abstract (but can still be custom defined). Interfaces do not contain code that perform actions, but rather the headers of defined methods and sometimes attributes (which are usually public, static, and final).
  • Interfaces are ultimately used to achieve abstraction, where they group related methods that all have empty bodies. Interfaces are accessed with the "implement" keyword from another class, and that class defines the bodies of the interface methods, thus overriding (redefining the method to have a body but with the same exact name and signature/headers) the interface method. Distinct classes can define a particular interface's methods differently. However, attributes can be declared and have their values defined in interfaces, and child classes will inherit the values defined in interfaces, or re-define the variables again (making sure to keep the same modifiers and variable name).
  • Similar to abstract classes, interfaces cannot be used to initialize objects (where the interface name follows the new keyword), and do not have "bodies" of code. An interface is implemented by separate classes, in which all of the methods defined in the interface must be overridden with method bodies of code defined in the class. It is important to note that a class can implement multiple interfaces (in contrast, a class can only extend one parent class), and an interface can be implemented by multiple classes (a parent class can be inherited by multiple child classes).

Examples:

// Creating a Java interface with the name Info
interface Info {
    // Header of the showInfo() method.
    public void showInfo();
}

// The Machine class implements the Info interface
class Machine implements Info {
    private int id = 7;

    public void start() {
        System.out.println("Machine started...");
    }

    // Need to override the showInfo() method of the Info interface
    @Override
    public void showInfo() {
        System.out.println("Machine ID is: " + id);
    }
}

// The Person class implements the Info interface
class Person implements Info {
    private String name;

    public Person(String name) {
        this.name = name;
    }

    public void greet() {
        System.out.println("Hello there!");
    }

    @Override
    public void showInfo() {
        System.out.println("Person name is: " + name);
    }
}

public class Application {
    public static void main(String[] args) {
        Machine mach1 = new Machine();
        mach1.start();
        // Call the showInfo() interface method defined in the Machine class
        mach1.showInfo();

        Person person1 = new Person("Bob");
        person1.greet();
        // Call the showInfo() interface method defined in the Person class
        person1.showInfo();

        // We can initialize info1 like this because the Machine class implements the Info interface.
        // info1 is a variable of type Info that points to an object reference of type Machine.
        // Since info1 is a variable of type Info, it can only be used to run the methods of the Info interface, which are redefined in the Machine class, which is referenced as an object here.
        Info info1 = new Machine();
        info1.showInfo();

        // info2 is a variable of type Info that points to an already defined object reference of class type Person
        Info info2 = person1;
        info2.showInfo();

        // mach1 and person1 are both objects that implement the Info interface, which means that are technically of type Info.
        // Pass the mach1 and person1 objects to the outputInfo() method, which in itself calls the showInfo() interface method, which is redefined in the Machine and Person classes.
        outputInfo(mach1);
        outputInfo(person1);
    }

    // Make this method static so that the Application class can directly access the method within itself (even though the method is private)
    // The outputInfo() method calls the showInfo interface method, which should be defined in the separate classes that are called in the parameter of outputInfo()
    private static void outputInfo(Info info) {
        info.showInfo();
    }
}

Application.main(null);
Machine started...
Machine ID is: 7
Hello there!
Person name is: Bob
Machine ID is: 7
Person name is: Bob
Machine ID is: 7
Person name is: Bob

25. Public, Private, and Protected

Notes:

  • When referencing an attribute or method of a particular class within that class itself, you do not need to prefix the variable name. In other words, notation like objectName.variableName is unnecessary, since variableName works by itself. This means that the access modifier does not matter; as long as you are referencing the variable within the same class, you can directly access it by name or optionally prefix dot notation.
  • Just for review, you typically should have one (and only one) public class in a file, and that public class's name should match the name of the file. However, you can have as many non-public classes with different names in the file as you want.
  • Access modifiers define the access level of attributes, methods, classes, and constructors, while non-access modifiers do not define the access level, but rather other forms of functionality.
  • Encapsulation is typically used to keep certain attributes or methods of a class hidden/private from the rest of the world, for the purpose of controlling the way people access these variables, and preventing unnecessary changes from happening to them.
  • Usually when we declare a particular variable public, that variable is defined as static and/or final, which means it belongs and remains constant (or has a constant change if final is not declared) to the class itself, so that it is relatively unchangeable by others.
  • The order in which modifiers appear does not necessarily matter all of the time, but there is a recommended order.
  • The public access modifier defines a class, attribute, method, or constructor as directly accessible to all other classes.
  • The private access modifier defines an attribute, method, or constructor as only directly accessible within the class it is declared in (can be accessed with getter methods from other classes though). Private variables are not inherited by child classes.
  • The protected access modifier defines an attribute, method, or constructor as only accessible within the same package (implies same file too) and any subsequent subclasses (even subclasses in separate packages, unlike the default access modifier) of the class it is declared in. This means that protected variables are inherited by child classes.
  • The default access modifier (happens when no access modifier is declared at all) defines a class, attribute, method, or constructor as only accessible by other classes in the same package (implies same file too). A child class can inherit default access modified variables from the parent class only if it is in the same package. It is possible for a child class to extend a parent class in another package using the import feature, but in that case, default variables are not inherited.

Examples:

class Plant {
    // Bad practice
    public String name;

    // Acceptable practice
    // The ID variable belongs to the Plant class itself and remains constant
    public final static int ID = 8;

    // The type variable is private, so it is not inherited by subclasses
    private String type;

    // The size variable is protected, so it is inherited by subclasses
    protected String size;

    // The height variable has the default access modifier
    int height;

    public Plant() {
        // this.name works, since the this keyword refers to the object that name is a part of.
        this.name = "Joe";

        // Even though it is private, the type instance variable is in the Plant class itself, so it can just be referenced by type.
        type = "plant";

        // size is defined as medium in the parent class
        size = "medium";

        height = 8;
    }
}

// Oak is a child class of the parent class Plant
class Oak extends Plant {
    
    public Oak() {
        // size was defined as medium in the parent class and that value is inherited by the child class
        // Here, we are overriding the value of size inherited from the parent class, and setting it to large
        this.size = "large";

        // height variable is inherited, since Oak is in the same package as Plant
        height = 10;
    }
}

class Field {
    // Create an instance variable that is an object of the Plant class.
    // The Plant class is accessible from the Field class because it has the default access modifier and is in the same file and package as the Field class.
    private Plant plant = new Plant();

    public Field() {
        // size is protected, but Field is in the same file and package as Plant
        System.out.println(plant.size);
    }
}

public class Application {
    public static void main(String[] args) {
        Plant plant = new Plant();
        // The name instance variable of the Plant class can be directly accessed outside of the class because it is public, using the prefix dot notation.
        System.out.println(plant.name);
        System.out.println(plant.ID);

        Oak oak = new Oak();
        System.out.println(oak.size);
        // Application is in the same package as Oak and Plant, so it can directly access height
        System.out.println(oak.height);

        // The constructor of Field will print the initialized value of size when an object of the Plant class is created
        Field field = new Field();
    }
}

Application.main(null);
Joe
8
large
10
medium
// Pretend that Plant is a part of the world package, and that Grass in another package
import world.Plant;

class Grass extends Plant {
    public Grass() {
        // This won't work because even though Grass is a child class of Plant, it is not in the same package as Plant, and height is a default access-modified variable.
        System.out.println(this.height);
    }
}
|   import world.Plant;
package world does not exist

26. Polymorphism

Notes:

  • Polymorphism, which means "many shapes/forms", involves the process of creating multiple classes that related to each other in some way through the inheritance of a super class. It is efficient in allowing code reusability amongst different classes. Polymorphism also states that an object of a sub-class can also have the variable type of the super-class (i.e. SuperClass variableName = new SubClass()).
  • However, it is important to note that the initialized variable type of an object variable defines the attributes and methods the variable has access to (can only access those listed in the class of the variable type), while the initialized object type of the variable defines the values of its attributes and the actions of its methods (the values and code defined in the class of the object type).
  • As inheritance allows different sub-classes to inherit the attributes and methods of a super class, polymorphism gives us the ability to take those attributes and methods and perform different kinds of tasks amongst different sub-classes that are each somewhat related to each other. Polymorphism basically gives sub-classes the ability to override the attributes and methods defined in the super-class.
  • Example: The Animal super-class, which has a sound() method, is inherited by the Pig, Cat, and Dog sub-classes. Pig, Cat, and Dog, will each have their own unique implementation/override of the inherited sound() method.

Examples:

class Plant {
    public void grow() {
        System.out.println("Plant is growing");
    }
}

class Tree extends Plant {
    @Override
    public void grow() {
        System.out.println("Tree is growing");
    }

    public void shedLeaves() {
        System.out.println("Leaves shedding");
    }
}

public class Application {
    public static void main(String[] args) {
        Plant plant1 = new Plant();
        Tree tree = new Tree();

        // plant2 of variable type Plant can refer to the same object of the Tree class that tree refers to.
        Plant plant2 = tree;
        // Although its variable type is Plant, plant2 refers to a Tree object, and so will run the Tree class's version of the grow() method
        plant2.grow();
        // Will not work, since plant2 has a variable type Plant, and Plant does not contain the shedLeaves() method.
        // plant2.shedLeaves();

        // Will work, since tree has a variable type Tree, and Tree does contain the shedLeaves() method.
        tree.shedLeaves();

        // tree is of variable type Plant, and so can be passed as a valid argument to the doGrow() method.
        // Since the grow() method is defined in the Plant class, it will run effectively
        // The Tree class's version of the grow() method will be used during the calling of doGrow(), since its object type is of the Tree class.
        doGrow(tree);
    }

    // The doGrow() method is public and static, so we can directly call it within the main test method (both main and doGrow are static methods, so they can directly access each other) in the same Application class.
    // The doGrow() method takes parameters of variable type Plant.
    // Polymorphism states that where ever a parent class type is expected, a child class type can be used there as well.
    public static void doGrow(Plant plant) {
        plant.grow();
        // An object of the Application class needs to be created in order to access the test() instance method within a static method
        Application app = new Application();
        app.test();
    }

    public void test() {
        System.out.println("Testing...");
        // An instance method can directly access a static method
        test2();
    }

    public static void test2() {
        System.out.println("Testing again...");
    }
}

Application.main(null);
Tree is growing
Leaves shedding
Tree is growing
Testing...
Testing again...

27. Encapsulation and the API Docs

Notes:

  • The purpose of encapsulation (Using access modifiers like private or protected) is primarily to purposely hide some of the inner-workings/elements of a particular class from the public. This prevents direct access to certain attributes or methods of the class, restricting users from directly accessing the state values of those variables. The state/inner values and actions of encapsulated attributes and methods are usually intended to only be directly used within the class (they are typically indirectly used outside the class). Encapsulation is also useful for preventing conflicts in variables amongst different classes. A state value of a variable refers to its instantaneous value during any point of the program's execution.
  • Encapsulated variables are typically accessed outside the class with getter and setter methods for those specific variables (encapsulated variables cannot be accessed straight away with dot notation with the object, but can be accessed with dot notation on the getters and setters with the object). The getter method allows users to read values of encapsulated variables, while the setter method allows users to change values of encapsulated variables, all-the-while the inner-workings of the class are modified but hidden away from the public for privacy and security. Getters and setters allow encapsulated attributes and methods to be applied toward external purposes outside of the class.
  • The accepted good practice is that whenever you can make a certain variable (attribute or method) private, make it private; if the variable needs to be inherited by child classes, make it protected; and if the user should be able to access the variable, make it public. Most data should be encapsulated, with the exception of constant variables.
  • An API stands for Application Programming Interface, which essentially allows the public to access certain features and functionalities of a program.
  • An API documentation is essentially a collection of references, descriptions, and examples (such as different kinds of attributes/methods and constructors, as well as the API's properties) that show users how to use and employ the functionality of a particular API.
  • It is important to note that when you are working with an object within a class, and the object is of that particular class (instantiating an object of the class within itself), we do not necessarily need getters and setters to access the encapsulated attributes and methods of that object; we can just access them using dot notation with the object name, and this is because the separate object's attributes and methods can be directly accessed within the class it was originally defined from (we just need the object name for reference, in order to differentiate its properties from that of the current object, which is represented by the current class code itself).

Examples:

class Plant {
    private String name;
    // Variables are typically declared as public when they are static and final, meaning their value remains constant and cannot be changed outside the class nor by the public.
    public static final int ID = 7;

    public String getData() {
        String data = "Some stuff " + getGrowthForecast();
        return data;
    }

    // Private methods cannot be directly access outside of the class, and are intended to only be used within the class.
    private int getGrowthForecast() {
        return 9;
    }

    // The public getter and setter methods of the name variable allow name to be accessed (though not directly) outside of the class
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

public class Application {
    public static void main(String[] args) {
        Plant plant = new Plant();
        System.out.println(plant.getData());
        plant.setName("Daniel");
        System.out.println(plant.getName());
    }
}

Application.main(null);
Some stuff 9
Daniel

28. Casting Numerical Values

Notes:

  • In Java, there are several different numerical variable types, such as int (stores integers) and double (can store numbers with or without decimals).
  • Type casting is a method used to convert data for a certain variable type to a different variable type. Type casting can be used for primitive variable types (note that special types of conversion methods different from type casting are used to convert from primitive to non-primitive types and vice versa. Basically, reference/non-primitive types use unique conversion methods rather than type casting), and during its process, data is not changed, but rather the data type, allowing us to see different kinds of conversions amongst different data types.
  • Widening casting involves converting a smaller data type to larger data type size, in which the type casting is done automatically by the program (changes are not necessarily major for the value being casted). Narrowing casting involves converting a larger data type to a smaller data type size, in which the type casting is done manually by the programmer (changes may be major for the value being casted).
  • Truncation is a form of approximation used when part of a number is chopped off or ignored (e.g. 3.7 --> 3), and typically occurs when an integer is divided from an integer in Java. Rounding is form of approximation used when the number is rounded to the nearest number that satisfies the appropriate place value. Rounding is usually used to round decimals to whole numbers (e.g. 7.8 --> 8).

Examples:

public class Application {
    public static void main(String[] args) {
        // Variable types for storing numerical values
        // 16-bits
        short shortValue = 55;
        System.out.println(shortValue);

        // 32-bits
        int intValue = 888;
        System.out.println(intValue);

        // 8-bits
        byte byteValue = 20;
        System.out.println(byteValue);

        // 64-bits
        long longValue = 23355;
        System.out.println(longValue);

        // float values have to end with f
        float floatValue = 8834.3f;
        // Alternative notation to instantiating float
        float floatValue2 = (float) 99.3;
        System.out.println(floatValue);
        System.out.println(floatValue2);

        // double values can end with decimal points
        double doubleValue = 32.4;
        System.out.println(doubleValue);

        // Use the non-primitive version of primitive variable types to access set methods to display useful information
        // For example, the non-primitive version of the double numerical type can be used to displayed the max value it can store
        // The non-primitive versions of primitive types are typically referenced similar to the primitive type name, but with the first letter capitalized and the full work being used (e.g. int becoming Integer).
        System.out.println(Double.MAX_VALUE);
        System.out.println(Byte.MAX_VALUE);

        // Notation for type casting
        // Narrowing casting
        // Here, manual casting is needed, since the long value may be too large to be stored in int, which is why type casting might be needed to perform the necessarily changes to the long value
        // Convert the value of longValue to an int, and set it equal to intValue
        intValue = (int) longValue;
        System.out.println(intValue);

        // Widening casting
        // Here, manual casting is not needed, because int is automatically converted to double, since its actual value itself does not necessarily change when it is converted to a floating point
        doubleValue = intValue;
        System.out.println(doubleValue);

        // Convert float to int. The decimal portion is just chopped off, and there is no rounding; this is known as truncation
        intValue = (int) floatValue;
        System.out.println(intValue);

        // Cast value of 130 to byte variable type
        // 127 is the max positive value of byte, so 130 will loop around starting at the min value of byte, and move up accordingly to its remaining value
        byteValue = (byte) 130;
        System.out.println(byteValue);

        // Example of type casting during operations
        int a = 10;
        int b = 3;
        double c = a/b;
        // Here, widening casting is used convert int to double
        // However, since a and b are both integers, their division will result in a truncated answer, since integers cannot hold decimals
        System.out.println(c);

        // All of these produce the same results
        // As long as at least one of the numbers in the operation is correctly casted to a double, the result will of the operation will also be a double, and that computation will be stored in a double variable
        double d1 = (double) a / (double) b;
        double d2 = a / (double) b;
        double d3 = (double) a / b;
        System.out.println(d1);
        System.out.println(d2);
        System.out.println(d3);
    }
}

Application.main(null);
55
888
20
23355
8834.3
99.3
32.4
1.7976931348623157E308
127
23355
23355.0
8834
-126
3.0
3.3333333333333335
3.3333333333333335
3.3333333333333335

29. Upcasting and Downcasting

Notes:

  • Upcasting and downcasting are primarily used for conversions between child and parent objects. During upcasting and downcasting, the object type remains the same, but the variable type changes.
  • Upcasting involves the typecasting of a child object to a parent object, and can be done implicitly/automatically. After upcasting, the child object can only access the inherited and overridden attributes/methods of its parent class, but can no longer access the new attributes/methods defined in the child class.
  • Downcasting involves the typecasting of a parent object to a child object, and cannot be done implicitly, but rather manually. After downcasting, the parent object can now access all of the attributes and methods, both overridden and new, found in the child class.
  • The variable type determines which attributes and methods can be accessed, while the object type determines the actual implementation of the attributes and methods.

Examples:

class Machine {
    public void start() {
        System.out.println("Machine started");
    }
}

class Camera extends Machine {
    public void start() {
        System.out.println("Camera started");
    }

    public void snap() {
        System.out.println("Photo taken");
    }
}

public class Application {
    public static void main(String[] args) {
        // The Machine object type cannot be referred to by the Camera child variable type, but can be referred to by the Machine parent variable type
        Machine mach1 = new Machine();
        Camera cam1 = new Camera();

        mach1.start();
        cam1.start();
        cam1.snap();

        // Upcasting
        // Set mach2 of variable type Machine to refer to the same Camera object that cam1 refers to
        // Moving up the class hierarchy, from child variable type Camera to parent variable type Machine
        Machine mach2 = cam1;
        // The object type is Camera, so the start() method overridden in Camera will be ran. The variable type is simply a reference to the object, and defines the range of attributes and methods that can be accessed.
        mach2.start();
        // mach2.snap() won't work, because the variable type is Machine, and snap() is not a method in the Machine class

        // Downcasting
        Machine mach3 = new Camera();
        // Set cam2 of variable type Camera to refer to the same Camera object that mach3 refers to
        // This way, all of the attributes and methods of Camera can be accessed with our new object
        // Notation for manual casting, which is needed for downcasting. This is because downcasting is more inherently unsafe than upcasting, as more changes are being made to the object variable itself (object type stay the same)
        // The Camera object type can be referred to both by the Camera child variable type and the Machine parent variable type
        Camera cam2 = (Camera) mach3;
        cam2.start();
        cam2.snap();

        // This won't work, because the parent object type Machine cannot be referenced by the child variable type Camera
        // Object types remain constant, which means the object type Machine cannot be converted to an object type Camera
        // Review: Child objects can be referred to by Parent types, but Parent objects cannot be referred to by Child types
        // Machine mach4 = new Machine();
        // Camera cam3 = (Camera) mach4;
    }
}

Application.main(null);
Machine started
Camera started
Photo taken
Camera started
Camera started
Photo taken

30. Using Generics

Notes:

  • A Generic class is basically a class that can work with different data types and objects. Generic entities operate on a parameterized type(s), which specify the type of data they will work with (i.e. the types of objects they can store and retrieve for the programmer) when instantiated as methods or objects. Generics are able to provide templates for certain classes in a sense. Generic classes are used to create useful data structures in Java such as ArrayList, LinkedList, HashSet, HashMap, etc.
  • A Generic method, working just like a normal method, takes a "type" parameter(s) that specifies the type of data that should be passed into the method (usually in between the diamond brackets <>), enabling a general usage of the method.
  • A Generic class, working just like a normal class, takes a "type" parameter(s) in a certain section of its definition (usually in between the diamond brackets <>), which specifies the type of data the object of the class works with in general.
  • As Generics follow a parameterized type(s), they eliminate the need for programmers to perform redundant type castings (Programmers used to have to type cast the data retrieved from certain data structure classes in order to convert from unwanted object to desired variable type. Now, Generics will return data of the appropriate type). So, for a Generic entity to be Generic, it needs to have one or more type parameters. Parameterized types basically allow Generics to hold and work with (e.g. add, remove, retrieve) a general range of data that fit within the specified data type(s), in turn allowing for type-safety during compile time.
  • It is important to note that when defining parameterized types within the <> of a Generic entity, they need to be specified as objects. This means that primitive types need to be specified in their wrapper class forms (e.g. int - Integer). A parameterized type can also be specified as an object (referred by the class name) of a custom class that you created. You can also have nested parameterized types, where the outer parameterized type represents some kind of list which has its own parameterized data type (e.g. type parameter is ArrayList).
  • Wrapper classes are basically classes that represent equivalent primitive type counterparts. For example, the wrapper class of int is Integer, and the wrapper class of char is Character. Wrapper classes are essentially the reference/object equivalents to primitive types, and should be defined with Generics in Java's Collection framework.

Examples:

class Animal {
    private String type = "animal";
}

public class Application {
    public static void main(String[] args) {
        // Before Java 5 with Generics was released
        // Old way of initializing an ArrayList
        ArrayList list = new ArrayList();
        // Append elements to and retrieve elements from the ArrayList
        list.add("apple");
        list.add("banana");
        list.add("orange");

        // In the old version, list.get(1) retrieves an object, and so we need to downcast the value of the object to get the desired String value
        String fruit = (String) list.get(1);
        System.out.println(fruit);

        // After Generics were introduced with Java 5
        // Modern way of initializing an ArrayList
        // Notice how the parameterized type is specified with the ArrayList in between the diamond brackets <>, during both variable type declaration and object type declaration.
        ArrayList<String> strings = new ArrayList<String>();
        // Append elements to and retrieve elements from the ArrayList
        // Modern way of modifying ArrayList is very similar to the old way, except we no longer need to type cast due to the parameterized types of Generics
        strings.add("cat");
        strings.add("dog");
        strings.add("alligator");

        String animal = strings.get(1);
        System.out.println(animal);

        // There can be more than one parameterized type defined in Generic entities
        // The multiple parameterized types are separated by commas
        // Initialize HashMap Generic data structure
        HashMap<Integer, String> map = new HashMap<Integer, String>();

        // Java 7 style of initializing Generics
        // The parameterized type only needs to be specified once (in the variable declaration), as the program automatically infers the parameterized type in the second part of declaration
        // The ArrayList holds objects of the created Animal class
        ArrayList<Animal> someList = new ArrayList<>();
    }
}

Application.main(null);
banana
dog

31. Generics and Wildcards

Notes:

  • Normally, a Generic entity such as ArrayList with a parameterized type object of a child class is NOT a subclass of a Generic entity with a parameterized type object of a parent class.
  • In Java, a wildcard, represented by the ? symbol in the type parameter, indicates that the Generic entity works with data of an unknown type. Wildcard Generics are declared in the parameters of methods. Because of this, the type of variable(s) the Generic entity in the method parameter ultimately works with is typically specified as an argument during the passing of another Generic (which itself has a specified type parameter) to the method.
  • Keep in mind that after the parameterized type of a wildcard Generic has been determined with the argument passed, the parameterized type needs to remain consistent (e.g. An ArrayList of type parameter String was passed as an argument for the ArrayList parameter with a wildcard type parameter. In the method, the ArrayList parameter will now be treated as an ArrayList with type parameter String). A wildcard is basically a special kind of type parameter that essentially dictates the type safety of Generics on a broader scale.
  • Upper bounds and lower bounds of wildcards cannot be specified at the same time. The lower bound of a wildcard indicates that the unknown type is limited to the specified class type, or any super class of the specified class type. The upper bound of a wildcard indicates that the unknown type is limited to the specified class type, or an sub class of the specified class type.

Examples:

// Import ArrayList class from the java.util library
import java.util.ArrayList;

// Every class created extends from the Object ultimate parent class
class Machine {
    @Override
    // Override toString() method inherited from the Object class
    public String toString() {
        return "I am a machine";
    }

    public void start() {
        System.out.println("Machine is started");
    }
}

class Camera extends Machine {
    @Override
    public String toString() {
        return "I am a camera";
    }

    public void snap() {
        System.out.println("Snap");
    }
}

public class Application {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<String>();
        list.add("one");
        list.add("two");
        showList(list);

        ArrayList<Machine> machList = new ArrayList<Machine>();
        // Add objects of the Machine class to the ArrayList
        // Since the object type is Machine, the program assumes that the variable type is Machine as it is not declared? (inferring the variable type from the object type)
        machList.add(new Machine());
        machList.add(new Machine());
        showMachList(machList);

        ArrayList<Camera> camList = new ArrayList<Camera>();
        // Add objects of the Machine class to the ArrayList
        // Since the object type is Camera, the program assumes that the variable type is Camera as it is not declared? (inferring the variable type from the object type)
        camList.add(new Camera());
        camList.add(new Camera());
        showWhateverList(camList);

        showWhateverMachineList(machList);
        showWhateverMachineList(camList);

        showWhateverCameraList(machList);
        showWhateverCameraList(camList);
    }

    // Make method static so that it can be directly accessed by the static main method, getting rid of the need of creating an object of the Application class
    // Parameter of showList() is of variable type ArrayList with parameterized type String
    public static void showList(ArrayList<String> list) {
        // Enhanced for loop
        for (String value : list) {
            System.out.println(value);
        }
    }

    // Parameter of showMachList() is of variable type ArrayList with parameterized type Machine
    public static void showMachList(ArrayList<Machine> machList) {
        for (Machine mach : machList) {
            System.out.println(mach.toString());
        }
    }

    // Usage of wildcard (indicated by ? symbol)
    // Parameter of showWhateverList() is of variable type ArrayList with any kind of parameterized type
    public static void showWhateverList(ArrayList<?> whateverList) {
        // Since wildcard is used, we have to refer to the objects of the ArrayList as Object, since Object is the parent class of all classes in Java
        for (Object value : whateverList) {
            // Object itself has a toString() method that runs when an object is printed. Here, Camera's toString() method overrides that of the Object class, since objects of Camera were added to the ArrayList
            // Since the variable type is Object, only attributes and methods a part of the Object class can be accessed.
            // If we wanted to use the attributes and methods of the other classes here, we would have to downcast the variable type Object to a variable type of a child class
            System.out.println(value);
        }
    }

    // Usage of wildcard while specifying the upper bound of the parameterized type to be of variable type Machine (which encompasses the Machine class, or any child class of Machine)
    public static void showWhateverMachineList(ArrayList<? extends Machine> list) {
        // Since the variable type is Machine, only attributes and methods found in the Machine class can be accessed, though they could have been overridden any child classes
        for (Machine value : list) {
            // The toString() method runs when an object is printed
            System.out.println(value);
            value.start();
        }
    }

    // Usage of wildcard while specifying the lower bound of the parameterized type to be of variable type Camera (which encompasses the Camera class, or any super/parent class of the Camera class)
    public static void showWhateverCameraList(ArrayList<? super Camera> list) {
        // Since lower bound is specified instead of upper bound, the Object class variable type needs to be used as sort of a parent in order to encompass objects of the Camera class and its super classes
        for (Object value : list) {
            System.out.println(value);
        }
    }
}

Application.main(null);
one
two
I am a machine
I am a machine
I am a camera
I am a camera
I am a machine
Machine is started
I am a machine
Machine is started
I am a camera
Machine is started
I am a camera
Machine is started
I am a machine
I am a machine
I am a camera
I am a camera

32. Anonymous Classes

Notes:

  • In Java, an anonymous class allows you to declare/define and instantiate a class at the same time. They are similar to normal local classes in terms of properties and behaviors, except for the fact that they do not have a name, and are created during instead of before the runtime of the program.
  • Anonymous classes primarily serve to provide a way of extending a class or implementing an interface. An anonymous class is used whenever you only want to utilize that local class once in your code (i.e. Only one object refers to the anonymous class, which is the object declared with the creation of an anonymous class that stems off a parent class or interface declaration. You can call as many attributes and methods from that object as you want, as long as they exist within the range of properties inherited from a parent class or implemented from an interface. Reminder that anonymous classes are typically declared to be children of parents classes and implementations of interfaces).
  • Anonymous classes ultimately help you make your code more concise, allowing you to essentially make real-time, temporary modifications to the attributes and methods of classes through a single object declaration.

Examples:

class Machine {
    public void start() {
        System.out.println("Starting machine...");
    }

    public void stop() {
        System.out.println("Machine stopped");
    }
}

interface Plant {
    // Reminder that you declare methods in interfaces, but you do not declare bodies of code, as they are defined by classes that implement the interface
    public void grow();
}

public class Application {
    public static void main(String[] args) {
        // Create reference variable mach1 to refer to object of Machine class
        Machine mach1 = new Machine();
        mach1.start();

        // Notation for creating an anonymous class that is not the Machine class itself, but rather a child of the Machine class
        // Notice how the anonymous class does not have a name, but rather inherits or overrides the attributes and methods of Machine
        Machine mach2 = new Machine() {
            @Override
            public void start() {
                System.out.println("Camera snapping");
            }
        };
        // Call multiple methods of the single object of the above anonymous class
        mach2.start();
        mach2.stop();

        // You cannot instantiate an object of an interface, as the interface cannot be implemented that way
        // You need to instead instantiate an object of a class that actually implements the interface
        // Notation for creating an anonymous class that is not the Plant interface itself, but rather a class that implements the Plant interface
        // With the anonymous class, we can instantiate an object, since the object is derived from a valid class that implements the interface
        Plant plant1 = new Plant() {
            // Need to override all of the attributes and methods defined in the interface
            @Override
            public void grow() {
                System.out.println("Growing...");
            }
        };
        plant1.grow();
    }
}

Application.main(null);
Starting machine...
Camera snapping
Machine stopped
Growing...

33. Reading Files Using Scanner

Notes:

  • A backslash in a String usually indicates a special character within the String. To fix this problem, we could put double backslashes instead of single backslashes, because a double backslash indicates that the special character really is a backslash. You could also use forward slashes as an alternative to the double backslashes.
  • The Scanner class of the java.util library can be used to read the contents of a specified file, or scan the inputs given by users through an inputStream such as System.in. It provides a variety of methods to read through different data types, usually line by line.
  • The "next" methods of the Scanner class, such as nextLine() and nextInt(), literally read the "next specified content" of a file or user input each time they are called (e.g. nextInt() reads the next integer in the file, nextDouble() reads the next decimal number in the file, and nextLine() reads the next remaining line of content in the file). If multiple different data types were to be on the same line, the program can (but does not necessarily need to; could for example use nextInt() to read only the integer and use nextLine() to read the remaining content on the line) read the whole line as a String, concatenating all of the different data types into a single content.
  • If an alternative next function to nextLine() is used, such as nextInt(), after the integer in the first line has been read, there will be a blank line that will be read before the rest of the content in the file is read. This is because after the last character of each line, there is an invisible character(s) that represents the line feed, or the end of the line. The nextLine() method accounts for these line feeds along with its selected content, while most of the other next methods do not, as they only read through their appropriate data type contents.

Examples:

// Import the File class from the java.io library
import java.io.File;
// Import the FileNotFoundException class from the java.io library
import java.io.FileNotFoundException;
// Import the Scanner class from the java.util library
import java.util.Scanner;

public class Application {
    // Indicates that the main program will just stop and throw a FileNotFoundException, in the case where the file path defined is not found on the system
    public static void main(String[] args) throws FileNotFoundException {
        // Set file path of the desired file on the project file system
        // I used the project relative file path instead of the absolute computer system file path used in the tutorial
        String filePath = "/home/dylanluo05/DylanLuoAPCSA/assets/java-fundamentals-resources/lesson-33-file.txt";
        // Create object of the Java File class, and pass filePath as an argument into the File class's constructor
        File textFile = new File(filePath);
        // Create object of the Java Scanner class, and pass the textFile object (instead of System.in, which indicates that Scanner will take user input. Here, the Scanner reads from the File we defined) as an argument into the Scanner class's constructor
        Scanner in = new Scanner(textFile);

        // nextInt() does not account for line feeds, while nextLine() does.
        // Use nextInt() to appropriately store the data in the first line of the file as an int, which is an integer variable type.
        int value = in.nextInt();
        System.out.println("Read value: " + value);
        // Read through the double value on the same line as the first integer read, and appropriately store it as a double variable type
        double floatingPoint = in.nextDouble();
        System.out.println("Read decimal: " + floatingPoint);
        // Line feed on the first line will be read after the integer and double data have been read
        in.nextLine();

        // Number the lines in the file after the first line that was just read
        int count = 2;
        // Loop to read the file line by line, after the first line that has already been read above
        // While the file still has another line to read, perform the following actions
        while (in.hasNextLine()) {
            // Store the word content of the currently selected line in the file into a String variable
            // The Scanner iterates through each line of the file from top to bottom
            String line = in.nextLine();
            System.out.println(count + ": " + line);
            count++;
        }
        // Here, the close() method will automatically close the specified file in the constructor after it has been scanned through
        in.close();
    }
}

Application.main(null);
Read value: 7
Read decimal: 7.7
2: Hello World
3: I am Dylan
4: I love programming, especially in Java
5: I love to work out
6: I love to play soccer
7: I hope to create a world-dominating AI

34. Handling Exceptions

Notes:

  • In Java, an exception is essentially an event during a program's execution in which an error occurs in the flow of the code, leading to the program's disruption and possible stoppage, and sometimes prompting the program to create an object (exceptions are actually objects of a class called Exception) detailing the exception and sending it to the runtime.
  • The throws keyword is defined in a method signature/declaration, and determines which kind of exception (should usually be only one) should be thrown from the method, ultimately defining the certain kind of error the program should handle if it occurs. Generally, we should use throws to handle checked exceptions, since unchecked exceptions do not need to be manually handled as they are automatically caught by runtime. Throwing an exception means creating an object for the exception, then throwing it to the runtime to display. The message presented by the runtime about the exception is known as the stack trace.
  • In Java, the try catch statement provides a way of handling exceptions in the program. The code block within the try statement define the code that the program will test for errors. The moment a line of code within the try statement has an error, the try statement immediately throws an exception to the catch statement.
  • The exception is essentially caught by the catch statement (parameter of catch statement defines which kind of exceptions it should look for. e.g. FileNoteFoundException e, where e is the object variable to store the FileNoteFoundException; Exception e, where e is the object variable to store any kind of exceptions, as indicated by the general name Exception), and the code block within the catch statement defines the action that will be taken if an error is found within the try statement.
  • Following the try catch statement you can optionally write a finally statement, and the code block in the finally statement defines the actions that will be taken after the try catch statement regardless of the result. The useful aspect of try catch is that the program will still run even if errors are found in the try statement (since the exception thrown by try is caught by catch and handled, instead of thrown to runtime). However, exceptions found in the catch statement or finally statement will be thrown to runtime.
  • Sometimes, methods will call each other, which means that multiple methods could encounter the same exception. This means that good practice is that each method should have a way (throw or try catch) to handle the exception. Any exceptions that may be thrown that may be encountered anywhere else in the program should be handled in some way (either thrown again or caught again).

Examples:

import java.io.File;
import java.io.FileReader;

public class Application {
    // Use throws keyword to allow the main method to handle FileNotFoundException checked exceptions
    public static void main(String[] args) throws FileNotFoundException {
        File file = new File("test.txt");
        
        // FileReader class serves as intermediate step to reading objects of File class
        FileReader fr = new FileReader(file);
    }
}

class ApplicationTwo {
    public static void main(String[] args) {
        File file = new File("test.txt");

        // Notation for try catch statement
        // Create FileReader object in the try statement
        // Catch statement specifically checks for FileNotFoundException
        try {
            FileReader fr = new FileReader(file);
            // This will not run as an exception is thrown before it
            System.out.println("Continuing...");
        } catch (FileNotFoundException e) {
            // If FileNotFoundException occurs, use the printStackTrace() message of the object of the FileNotFoundException class
            // e.printStackTrace();

            // Use toString() to print a clear representation of the file object
            System.out.println("FileNotFoundException occurred. File not found: " + file.toString());
        }
    }
}

class ApplicationThree {
    // If FileNotFoundException occurs in the main method, the method will by default throw it to runtime
    // But if FileNoteFoundException occurs in a try statement, the catch statement will catch the exception and take its own action, preventing the program from throwing the exception to runtime
    public static void main(String[] args) throws FileNotFoundException {
        // Directly access openFile() method from static main tester method
        // Since openFile() throws an exception itself, and is called in the main method, we need to choose what to do with the exception
        // We can either throw the exception from main to runtime, and/or use a try catch statement to catch the exception thrown by openFile()
        try {
            openFile();
            // Use Exception e to allow catch statement to handle any kind of exception
        } catch (Exception e) {
            System.out.println("Could not open file. File not found");
        }
    }

    // Make method static so we can directly access it in the static main method. This is because both methods are static and are in the same class
    // The openFile() method throws the FileNotFoundException itself
    public static void openFile() throws FileNotFoundException {
        File file = new File("test.txt");
        
        FileReader fr = new FileReader(file);
    }
}

ApplicationTwo.main(null);
ApplicationThree.main(null);
// Most exceptions stop the runtime of the program completely
Application.main(null);
FileNotFoundException occurred. File not found: test.txt
Could not open file. File not found
---------------------------------------------------------------------------
java.io.FileNotFoundException: test.txt (No such file or directory)
	at java.base/java.io.FileInputStream.open0(Native Method)
	at java.base/java.io.FileInputStream.open(FileInputStream.java:219)
	at java.base/java.io.FileInputStream.<init>(FileInputStream.java:157)
	at java.base/java.io.FileReader.<init>(FileReader.java:75)
	at Application.main(#14:1)
	at .(#42:1)

35. Multiple Exceptions

Notes:

  • In Java, a method can be modified to be able to handle multiple exceptions, defined in the method signature and separated by commas. The thing is that the method can only throw one exception, but it has the potential to handle any of the exceptions defined, and the exception that is handled depends on the order in which the exceptions occur (whichever occurs first is handled first).
  • The throw keyword allows us to explicitly cause the program to throw a type of exception (checked or unchecked) from a block of code. The throw new command allows us to manually throw an object of the Exception class during the program, also enabling us to define a custom error message in the constructor of the object (different exception classes may have different constructor parameters). The throw keyword stops the running of the program.
  • The throws keyword, which is defined with exceptions separated commas in the method signature, helps us define a list of potential exceptions that may occur in a method. We can use try catch statements to choose how to handle each kind of exception (each catch statement parameter takes a specific type of exception, and the code block within the catch statement can take custom action with the program still running, or throw the appropriate exception to stop the running of the program).
  • In a try multi-catch statement, one catch statement typically lists multiple different exceptions that could occur, separated by |, and defines what action should be taken if any of those exceptions occur.
  • The classes that represent each kind of exception are all sub-classes of the Exception parent class. This means the Exception class can encapsulate any kind of exception that could occur. Child exceptions should always be checked before parent exceptions, since parent exceptions always (encapsulate) catch errors of child exceptions, but child exceptions cannot catch errors from the other children of parent exceptions. If a parent exception checker were to be placed before the child exception checker, the child exception would never be reached, because it would be handled by the parent exception checker.

Examples:

import java.io.IOException;
import java.text.ParseException;

// The exceptions in this program are mostly checked exceptions that are checked during compile time. This means they need to be handled for the program to run. More on this topic later

class Test {
    // The run() method can throw either the IOException or the ParseException
    public void run() throws IOException, ParseException {
        // throw new IOException();
        // You cannot simultaneously throw 2 different exceptions, so only throw one exception here, as we do not have a try catch statement to sort out the different cases
        // The object of the ParseException class takes 2 parameters for its constructor
        // Throw a custom exception message in the case of a ParseException. The program will take the default action of printing out a stack trace in the case of an IOException
        throw new ParseException("Error in command list.", 2);
    }

    // FileNotFoundException is a child class of IOException
    public void input() throws IOException, FileNotFoundException {
        throw new FileNotFoundException("File not found.");
    }
}

public class Application {
    public static void main(String[] args) {
        Test test = new Test();
        // All of the possible exceptions thrown by run() need to be handled by the main() method too
        // Try statement comes with multiple catch statements, each defining the case for a different kind of exception
        try {
            test.run();
        } catch (IOException e) {
            // The printStackTrace() method prints an error message just like throw, but it does not stop the runtime
            e.printStackTrace();
        } catch (ParseException e) {
            System.out.println("Couldn't parse command file.");
        }

        try {
            test.run();
            // Exception (variable type of variable e) means the catch statement can catch any kind of exception here
        } catch (Exception e) {
            e.printStackTrace();
        }

        // try multi-catch statement, which defines an action for IOException and ParseException
        try {
            test.run();
        } catch(IOException | ParseException e) {
            e.printStackTrace();
        }

        // Catch blocks are checked in the chronological order in which they are defined
        // FileNotFoundException is a child class of IOException, which means that IOException encapsulates FileNoteFoundException
        // This means that FileNotFoundException should be checked first, as if IOException were to be placed before, the catch statement containing FileNotFoundException would not be reached
        try {
            test.input();
        } catch (FileNotFoundException e) {
            System.out.println("FileNotFoundException occurred");
        } catch (IOException e) {
            System.out.println("IOException occurred");
        }
    }
}

Application.main(null);
Couldn't parse command file.
java.text.ParseException: Error in command list.
	at REPL.$JShell$14G$Test.run($JShell$14G.java:25)
	at REPL.$JShell$15H$Application.main($JShell$15H.java:33)
	at REPL.$JShell$21.do_it$($JShell$21.java:18)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)
	at io.github.spencerpark.ijava.execution.IJavaExecutionControl.lambda$execute$1(IJavaExecutionControl.java:95)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
	at java.base/java.lang.Thread.run(Thread.java:829)
java.text.ParseException: Error in command list.
	at REPL.$JShell$14G$Test.run($JShell$14G.java:25)
	at REPL.$JShell$15H$Application.main($JShell$15H.java:41)
	at REPL.$JShell$21.do_it$($JShell$21.java:18)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)
	at io.github.spencerpark.ijava.execution.IJavaExecutionControl.lambda$execute$1(IJavaExecutionControl.java:95)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
	at java.base/java.lang.Thread.run(Thread.java:829)
FileNotFoundException occurred

36. Runtime vs. Checked Exceptions

Notes:

  • Checked exceptions are exceptions that are found during compile time and force you to handle them. Runtime (unchecked) exceptions are exceptions that are found during the actual runtime of the program, and do not necessarily force you to handle them all the time. Both checked and runtime exceptions need to be handled or fixed in some kind of way in order for the program to run properly.
  • If a compile time error occurs, the entire program just isn't able to execute. If a runtime error occurs, the part of the program before the line of the code that caused the exception is able to execute, but the rest of the program after it is unable to.

Examples:

public class Application {
    public static void main(String[] args) {
        // Checked exception (checked during compile time)
        // Thread.sleep(111);

        // Runtime exception (checked during runtime)
        // This prompts an ArithmeticException, which is a child class of RuntimeException, which in turn is a child class of the Exception class
        // int value = 7;
        // value = value/0;

        // Another runtime exception
        // text is a String reference to null, and you cannot really call methods on a reference to a null value
        // Will produce NullPointerException
        // String text = null;
        // System.out.println(text.length());

        // Another runtime exception
        // Array texts only goes up to index 2, which is why texts[3] will produce an ArrayIndexOutOfBoundsException
        String[] texts = {"one", "two", "three"};
        try {
            System.out.println(texts[3]);
        } catch (RuntimeException e) {
            // The toString() method of exception object e with variable type RuntimeException prints a clear text representation of the ArrayIndexOutOfBoundsException (RuntimeException encapsulates ArrayIndexOutOfBoundsException) that occurred
            System.out.println(e.toString());
        }

        System.out.println("Exceptions are pretty interesting, right?");
    }
}

Application.main(null);
java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
Exceptions are pretty interesting, right?

37. Abstract Classes

Notes:

  • In Java, abstraction is essentially hiding certain details about attributes and methods, and only providing essential information to users.
  • Abstract is a non-access modifier, and can be used on classes and methods. An abstract class basically serves as a base class, which is restricted in that it cannot be used to create objects. To set and get the actual values and bodies of the attributes and methods of an abstract class, it must be inherited by the child class, in which the values are actually established and accessed.
  • An abstract class can contain attributes, methods, and abstract methods. An abstract method can only be defined within an abstract class, and does not have a body, which means the action it does (its body) can only defined in a child class that inherits the abstract class. The normal attributes and methods of an abstract class can have values and bodies, though they can only be accessed through a child class that extends the abstract class (note that private instance variables of a parent class can be accessed/inherited by a separate/child class if that instance variable has public getters and setters).
  • Abstract methods defined in an abstract class should be coupled with the abstract keyword and have no body, but should not have the abstract modifier and should have a body when re-defined in child classes. Normal attributes and methods defined in either the abstract class or the child classes should not have the abstract keyword, and their values and bodies can be defined in both the abstract class and child classes.
  • The main difference in purpose between abstract classes and interfaces is that abstract classes are not intended to create objects from, as they serve to provide common functionality (sort of like a base parent class) to child classes that are related to each other (we want to create objects of these child classes), while interfaces serve to provide common functionality to child classes that are not necessarily related to each other. For example an abstract class Animal can be extended to child classes Pig and Cow (class hierarchy), which are related, while an interface showInfo can be implemented by child classes Machine and Person, which are not that related.
  • A class can implement multiple interfaces, but can only have one parent class (although it could technically inherit from the parent class of its parent class). In an interface you should have no implementation of methods (no bodies defined), but in an abstract class you can have implementation of methods (bodies defined for conventional methods but not abstract methods).

Examples:

// Make the Machine class abstract, so that we cannot create objects of it, since it serves as a base class for child classes
abstract class Machine {
    // Private attribute id. You cannot necessarily have abstract attributes, as you can set values for attributes of an abstract class in the abstract class itself
    // Use encapsulation to hide details of attributes
    private int id;

    // Provide getters and setters for the private instance variable id, so that classes that inherit Machine can actually set and get the value of id
    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    // Define an abstract method called start(). Note that it does not have a body
    public abstract void start();

    public abstract void doStuff();

    public abstract void shutDown();

    // Define a normal method called run(). Note that it does have a body
    public void run() {
        // Call abstract methods in the non-abstract method run(), which is also inherited to child classes
        // These abstract methods will ultimately have to be defined in the child classes, in order for run() to compute properly
        start();
        doStuff();
        shutDown();
    }
}

// Both Camera and Car extend from Machine, implying they have some common functionalities
// Camera and Car are conventional classes, meaning they can define the bodies of the abstract methods defined in Machine
class Camera extends Machine {
    // Implement once-abstract method start(), by overriding it and giving the method a body
    @Override
    public void start() {
        System.out.println("Starting camera");
    }

    @Override
    public void doStuff() {
        System.out.println("Do stuff in camera");
    }

    @Override
    public void shutDown() {
        System.out.println("Shutting down camera");
    }
}

class Car extends Machine {
    @Override
    public void start() {
        System.out.println("Starting car");
    }

    @Override
    public void doStuff() {
        System.out.println("Do stuff in car");
    }

    @Override
    public void shutDown() {
        System.out.println("Shutting down car");
    }
}

public class Application {
    public static void main(String[] args) {
        Camera cam1 = new Camera();
        cam1.setId(6);
        System.out.println(cam1.getId());
        // Call inherited method run() from cam1 object, which was not overridden and maintained its original definition from Machine
        cam1.run();

        Car car1 = new Car();
        car1.setId(1);
        System.out.println(car1.getId());
        car1.run();
    }
}

Application.main(null);
6
Starting camera
Do stuff in camera
Shutting down camera
1
Starting car
Do stuff in car
Shutting down car

38. Reading Files with File Reader

Notes:

  • The FileReader class in Java is a way of reading the contents of files. FileReader is typically used to read a stream, or line, of characters from a particular file when called. FileReader can be used to read a line of characters, and store that data in the form of bytes (which would later be converted to characters either by FileReader or more efficiently BufferedReader) with the process of buffering.
  • A buffer is essentially a linear and finite sequence of values that are of a particular primitive type. The BufferedReader class in Java serves an efficient way of reading and buffering characters from a character byte stream.
  • When it comes to reading files in Java, checked exceptions may be frequently met, so it is important to handle these exceptions in the program, usually in the form of try catch statements. Remember that a try statement can be accompanied by multiple catch statements, each specifying a different type of exception(s).
  • Reminder: Variables declared in Java are limited to the scope of the curly brackets around it. It can inside anywhere inside the brackets, but not outside the brackets. A variable can be declared in a wider scope, and its value can be changed either within that scope or within a more inner scope.

Examples:

import java.io.File;
import java.io.FileReader;
import java.io.FileNotFoundException;
import java.io.BufferedReader;

public class Application {
    public static void main(String[] args) {
        // File class for Java's representation of the file path, attributes, and systems
        File file = new File("/home/dylanluo05/DylanLuoAPCSA/assets/java-fundamentals-resources/lesson-38-file.txt");

        // Define BufferedReader outside the try catch statement, so that it can be closed after the first set of try catch statements have been run through
        // Give br a temporary null value as if the first catch statement executes before br gets a value, the program will not be able to execute the close() method later, since br does not have a value
        BufferedReader br = null;
        // Nested try catch statements
        try {           
            // FileReader class for Java to actually read the contents of the file object, and store the data in bytes
            FileReader fr = new FileReader(file);
            // BufferedReader class to buffer the byte data from FileReader into readable char data
            br = new BufferedReader(fr);

            // Do not define the actual value of String variable line yet, as Strings are immutable. Each time a line is read, store that stream of characters into line. Each time the value of line changes, a brand new String is created and stored into memory.
            // Use StringBuilder actually work with mutable Strings
            String line;
            // While there is still actual content to read from the file, print out each line of the file using the readLine() method
            // The readLine() method reads a particular line of the file provided in bytes by FileReader, and converts it to char (readable format). Each time the readLine() method is called, the next current line of the file is read
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (FileNotFoundException e) { 
            // Indicate that the file name that was not found on the system (for FileReader)
            // Use toString() to represent the file name in an appropriate format
            System.out.println("File not found: " + file.toString());
        } catch (IOException e) {
            // Indicate that the file was unable to be read (for the readLine() method of BufferedReader)
            System.out.println("Unable to read file " + file.toString());
            // The finally statement always runs regardless of the result of the try catch statement
        } finally {
            // New try catch statement to possibly re-throw exceptions that are met again. This is usually proper convention, as it indicates any further errors down the method hierarchy
            try {
                // BufferedReader is at the top of the chain, as it is reading the FileReader which in turn is reading the File, so it needs to be closed for the purpose of preventing memory leaks due to open files
                // Because of this, closing BufferedReader will ultimately close both FileReader and File
                br.close();
            } catch (IOException e) {
                // IOException to account for errors produced by the close() method of BufferedReader
                System.out.println("Unable to close file: " + file.toString());
            } catch (NullPointerException e) {
                // NullPointerException for if the BufferedReader object has a null value
                // This exception probably would not even be met, since if the file were to be null, it would likely be handled by the catch statements above
                System.out.println("Null Pointer Exception: " + file.toString());
            }
        }
    }
}

Application.main(null);
first line
second line
third line

39. Try-With-Resources

Notes:

  • The built-in AutoCloseable interface of Java indicates that a class implementing it should have some adequate form of the close() method.
  • The try-with-resources statement in Java is very similar to the try catch statement. The notation is mostly the same, except the try statement can now take parameters/clauses, and within the parameters/clauses, you can declare the resources (or objects that work with resources) that the try statement will check and work with. Resources may include files or sockets, which are opened when they are accessed, and should be closed to prevent memory leaks.
  • The catch statement in the try-with-resources serves basically the same purpose, handling any exceptions that may be thrown during the running of the try statement, or any exceptions thrown while working with or closing the resource(s), and the finally statement will run no matter the outcome of the try and catch statements.
  • Objects of classes that implement AutoCloseable, which are declared within try-with-resources, will automatically call the close() method after finishing performing all of its called actions. If any errors are met before the close() method (in the try statement), the try-with-resources will call the catch statement, and the rest of the try statement will not be executed (known as try exception). However, the catch statement will check again for the close() method, which is ran at the very end of the try-with-resources (known as try-with-resources exception, which may become a suppressed exception if a try exception is met before the same try-with-resources statement. A suppressed exception is basically one that is thrown but somehow is ignored, and mainly appears within try, catch, and finally statements).

Examples:

import java.io.File;
import java.io.FileReader;
import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;


// Temp needs to have a close() method because it implements AutoCloseable
class Temp implements AutoCloseable {
    // Override the close() method provided by AutoCloseable
    // The throws keyword indicates that the close() method is capable of handling, or throwing, exceptions of type Exception (basically any exception)
    @Override
    public void close() throws Exception {
        System.out.println("Closing!");
        // Manually throw an Exception at the end of the close() method
        throw new Exception("oh no!");
    }
}

public class Application {
    public static void main(String[] args) {
        // Notation for try-with-resources
        // The close() method may throw an exception, and that exception must be handled in some way in the main method
        try(Temp temp = new Temp()) {
            // The close() method of the temp object (which implements AutoCloseable) will automatically be called after the try-with-resources has fully been executed
            System.out.println("Try-with-resources example");
        } catch (Exception e) {
            // Print error message in standard format
            e.printStackTrace();
        }

        File file = new File("/home/dylanluo05/DylanLuoAPCSA/assets/java-fundamentals-resources/lesson-39-file.txt");
        // Use FileReader and BufferedReader to read the file line by line (FileReader) and effectively get all the characters in each line (BufferedReader)
        // Don't store the new object of FileReader in a variable, since we don't reference it again. Here, we just need to pass the new object of FileReader into the constructor of BufferedReader
        // Try-with-resources with multiple catch statements
        try(BufferedReader br = new BufferedReader(new FileReader(file))) {
            // Perform actions on the declared resource (object that works with files) in the try clause above, inside the try statement
            String line;
            // While there is still actual content to read from the file, print out each line of the file using the readLine() method
            // The readLine() method reads a particular line of the file provided in bytes by FileReader, and converts it to char (readable format). Each time the readLine() method is called, the next current line of the file is read
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (FileNotFoundException e) {
            System.out.println("Can't find file " + file.toString());
        } catch (IOException e) {
            System.out.println("Unable to read file " + file.toString());
        } finally {
            System.out.println("End of program");
        }
    }
}

Application.main(null);
Try-with-resources example
Closing!
java.lang.Exception: oh no!
	at REPL.$JShell$12G$Temp.close($JShell$12G.java:30)
	at REPL.$JShell$13P$Application.main($JShell$13P.java:28)
	at REPL.$JShell$34.do_it$($JShell$34.java:21)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)
	at io.github.spencerpark.ijava.execution.IJavaExecutionControl.lambda$execute$1(IJavaExecutionControl.java:95)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
	at java.base/java.lang.Thread.run(Thread.java:829)
Dylan
will
take
over
the
mf
world!
End of program

40. Creating and Writing Text Files

Notes:

  • In Java, the FileWriter class is essentially used to change or write data in the form of characters to text files. If the specified file path does not exist, FileWriter will create the file itself.
  • The BufferedWriter class is essentially used to change or write data to text files while utilizing a character-output stream (contains character bytes that need to be buffered), effectively and efficiently buffering the character-stream passed into its constructor that is to be written into the text files.
  • As a reminder, a buffer is essentially an area of memory that is allocated toward temporarily saving data (used when working with files) while it is moved from one place to another (e.g. from program to text file), and buffering is the act of facilitating the data flow and transfer between these 2 places.
  • The key difference between FileWriter and BufferedWriter is that FileWriter writes that character-stream directly into the text file, while BufferedWriter internally buffers the character-stream into the text, effectively allowing for less IO operations and thus better overall performance.
  • FileWriter and BufferedWriter, like FileReader and BufferedReader, are usually used in correspondence, where FileWriter (initially accesses the file and inputted character streams) is passed into the constructor of BufferedWriter (buffers the character streams and appends them to file). Note that whenever FileWriter and BufferedWriter are used, they will make changes to the original state of the file in its creation (usually blank), not its latest state, which means you cannot add to changes throughout multiple runs of the program (the file will refresh to blank at the beginning of each run of the program). However, you can save the changes to the file, and the file will not refresh unless you run the program again.

Examples:

import java.io.File;
import java.io.FileWriter;
import java.io.BufferedWriter;
import java.io.IOException;

public class Application {
    public static void main(String[] args) {
        File file = new File("/home/dylanluo05/DylanLuoAPCSA/assets/java-fundamentals-resources/lesson-40-file.txt");
        // Try-with-resources statement
        // Declare resource(s) in the try clause
        // Try-with-resources automatically closes resources
        // Use FileWriter and BufferedWriter to access/create a file (FileReader), and change the content within it with buffered inputted character streams (BufferedReader)
        try (BufferedWriter br = new BufferedWriter(new FileWriter(file))) {
            // The write() method of BufferedReader can append new text/Strings to the current line of the file that the program is on
            br.write("This is line one");
            // The newLine() method moves the program to the next line of the file
            br.newLine();
            br.write("This is line two");
            br.newLine();
            br.write("Last line");
        } catch (IOException e) {
            System.out.println("Unable to read file " + file.toString());
        } finally {
            System.out.println("End of program");
        }

    }
}

Application.main(null);
End of program

41. The Equals Method

Notes:

  • The == (equal to) sign in Java is a relational/comparison operator, that returns true if 2 values are the same, and false otherwise. For primitive types, == checks if the 2 values are equal. For reference (non-primitive) types, == checks if the 2 references refer to the same object. Back to primitive types, == returns true if 2 values are of the same data type and have the same value, or if 2 values are of different data types but can be converted to the same data type and have the same value, and false otherwise.
  • The equals() method in Java is inherited by all objects from the Object superclass, and is typically used to compare the values of object (reference) variables semantically (often in terms of attribute/property values, and in terms of object type and sometimes variable type) to check for equality (return true if the defined properties in the equals() method are equal in both objects, and false otherwise). The equals method is typically overridden in classes to define how objects of that class should be compared, in terms of which attributes or other kinds of properties should be checked.
  • A general rule of thumb is that when checking for equality semantically, use == for comparing primitive types, and equals() for comparing non-primitive (object) types.
  • The String class, like all other classes, inherits the Object class, and has its own implementation of the equals() method. The equals() method for Strings compares the contents of 2 String, are returns true if the characters in both Strings are all the same (also same order and same number of characters), and false otherwise.
  • In Java, the default toString() method of the Object class outputs a string representation of a particular object, which comprises the package and class name, as well as the hexadecimal memory location. The toString() method is automatically called when any value is printed to the Java console, and can be explicitly called on objects. A hash code is usually a unique integer value that is assigned to all created objects (generated by defined hashing algorithm. 2 objects can have the same hash code if generally they have equal properties and are of the same class (satisfying conditions of equals() method)), and a hash code of a particular object can be retrieved using the default hashCode() method. It is important to note that objects that are deemed equal by the equals() method have the same hash code, since the properties checked in the equals() method are also used in the hashing algorithm of the hashCode() method. equals() and hashCode() usually both need to be overridden in a particular class.

Examples:

class Person {
    private int id;
    private String name;

    public Person(int id, String name) {
        this.id = id;
        this.name = name;
    }

    // Override toString() method inherited from ultimate parent Object class
    // Conventional notation for toString() method
    @Override
    public String toString() {
        return "Person [id=" + id + ", name=" + name + "]";
    }

    // Override equals() method inherited from ultimate parent Object class
    // Conventional notation for equals() method
    // Here, the equals() method checks to see if the id and name attributes of 2 objects are the same. Notice how some preconditions are checked before the actual values, such how equals() checks for null values
    // Notice how the equals() method compares the properties of the object it is called on to the properties of the object passed into its parameter
    @Override
    public boolean equals(Object obj) {
        // Check if both objects refer to the same object, and immediately return true if that's the case
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        // The object passed into the parameter is of variable type Object, for the purpose of preventing exceptions in the case objects of different variable types (classes) are compared, in which case equals() will return false
        if (getClass() != obj.getClass()) {
            return false;
        }
        // Start comparing properties once it is known that the 2 objects have the same object type, and make sure both objects have the same variable type to allow for effective property comparing
        Person other = (Person) obj;
        if (id != other.id) {
            return false;
        }
        if (name == null) {
            if (other.name != null) {
                return false;
            }
        } else if (!name.equals(other.name)) {
            return false;
        }
         return true;
    }

    // The inner-workings of the default hashCode() method (hashing algorithm)
    // Notice how hashCode() takes the same properties defined in equals() into consideration, showing how 2 objects can have the same hash code if they are equal semantically
    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + id;
        result = prime * result + ((name == null) ? 0 : name.hashCode());
        return result;
    }
}

public class Application {
    public static void main(String[] args) {
        Person person1 = new Person(5, "Bob");
        Person person2 = new Person(8, "Sue");
        Person person3 = new Person(5, "Bob");
        
        // Will return false because person1 and person2 refer to different objects 
        System.out.println(person1 == person2);
        // Even though person1 and person3 have the same values in terms of properties (equal semantically), this will return false, because they refer to different objects, which in terms of memory are different
        System.out.println(person1 == person3);

        // Use equals() method inherited from Object superclass to compare objects semantically (in terms of properties, not object stored in memory)
        System.out.println(person1.equals(person2));
        System.out.println(person1.equals(person3));

        // Experiment with wrapper classes, which are non-primitive counterparts to primitive data types
        // Wrapper classes are essentially like objects, so == will check if the variables refer to the same object, not their actual value
        Double value1 = 7.2;
        Double value2 = 7.2;
        // Will return false, since Java doesn't automatically set 2 references of Double initialized with the same value to the same object
        System.out.println(value1 == value2);
        // Will return true, since equals() checks actual values
        System.out.println(value1.equals(value2));

        Integer number1 = 7;
        Integer number2 = 7;
        // Will return true, since Java does automatically set 2 references of Integer initialized with the same value to the same object
        System.out.println(number1 == number2);
        // Will return true, since equals() checks actual values
        System.out.println(number1.equals(number2));

        String word1 = "hey";
        String word2 = "hey";
        String word3 = "hey".substring(0, 3);

        // Will return true, since Java does automatically set 2 references of String initialized with the same content to the same object
        System.out.println(word1 == word2);
        // Will return false, since even though word1 and word3 have the same content, they didn't initially have the same content, and so were not referenced to the same String object
        System.out.println(word1 == word3);

        // Use equals() to effectively semantically compare the content of 2 Strings, regardless of their initial content
        System.out.println(word1.equals(word2));
        System.out.println(word1.equals(word3));

        // The default toString() method of the Object class outputs the package and class name, as well as the hexadecimal memory location
        System.out.println(new Object());
        // Use hashCode() method to output unique hash code of instantiated object of Object superclass
        System.out.println(new Object().hashCode());
        // These 2 objects have different hash codes because they are not equal in terms of Object's equals() method (checks if references refer to same object)
        System.out.println(new Object().hashCode());

        // These 2 objects have the same hash code because they have equal properties and are of the same class (so they are equal in terms of Person's equals() method)
        Person test1 = new Person(7, "Dylan");
        Person test2 = new Person(7, "Dylan");
        System.out.println(test1.hashCode());
        System.out.println(test2.hashCode());
    }
}

Application.main(null);
false
false
false
true
false
true
true
true
true
true
true
true
java.lang.Object@16ccc45e
843134895
774477045
66512222
66512222

42. Inner Classes

Notes:

  • In Java, an inner class is a class that is nested inside another class (or sometimes interface). Inner classes serve to group related classes together, allowing them to collectively function as a whole for the outer class.
  • While outer classes can only be declared as public or as the default access modifier, inner classes can be declared as public, private, protected, or default, allowing them to have the properties of the just mentioned access modifiers (e.g. private inner classes are only accessible within the outer class, and protected inner classes are only accessible within the same package of the outer class or any subclasses, similar to attributes and methods).
  • Outside classes cannot be static, but inner classes can. Static inner classes, similar to static attributes and methods, belong to the outer class itself (there is only one copy of it for the class and its subsequent objects), which means that a static inner class can be accessed without instantiating the outer class first (i.e. objects of a non-static inner class can only be created after an object of the outer class has been instantiated, but objects of a static inner class can be created without first creating an object for the outer class). A static inner class can have both static and non-static members (e.g. attributes, methods, but not constructors), but cannot directly access the instance (non-static) variables of the outer class (an object of the outer class would need to be created). A non-static inner class however, can have both static and non-static members, and can also directly access both static and non-static variables of the outer class.
  • Basically, non-static inner classes are grouped together for common functionality, while static inner classes are not necessarily associated to the instance variables of the outer class, but are still related to the outer class.

Examples:

class Robot {
    private int id;

    // Nested class that is a kind of inner-class
    // Nested classes can directly access the instance (non-static) attributes and methods of the outer classes
    // Make inner class public so that objects of it can be created outside of the outer class
    public class Brain {
        public void think() {
            System.out.println("Robot " + id + " is thinking");
        }
    }

    // Make inner class private so that objects of it can only be created within the outer class
    private class Legs {
        private String name = "Robot's legs";

        private void run() {
            System.out.println("Running");
        }
    }

    // Default access modifier so that objects of it can be created within the same package as outer class
    static class Battery {
        public void charge() {
            System.out.println("Battery charging...");
        }
    }

    public Robot(int id) {
        this.id = id;
    }

    public void start() {
        System.out.println("Starting robot " + id);
        // Outer class can access attributes and methods of inner classes by creating objects of the inner classes
        Brain brain = new Brain();
        brain.think();
        Legs legs = new Legs();
        // The outer class can even access the private attributes and methods of the inner class using objects as a reference, while outside classes can not (getters and setters would be needed for private attributes, and non-private methods of the class would be needed to run the private methods)
        System.out.println(legs.name);
        legs.run();

        // Like anonymous classes, inner classes declared within a method should have access to local variables (variables within the same method), but the local variables should conventionally be final (constant)
        final String name = "Robert";

        // You can even declare inner classes within methods
        // The inner class has direct access to variables defined inside the method, as well as variables within the outer class that the method itself is in
        // Nested classes inside methods do not have access modifiers, because their scope is limited to the methods, which means that they cannot be accessed outside of the method
        class Temp {
            public void doSomething() {
                System.out.println("ID is: " + id);
                System.out.println("My name is: " + name);
            }
        }

        Temp temp = new Temp();
        temp.doSomething();
    }
}

// Reminder that you can only have one public class per file, but you can have as many classes in a file as you want. Public class has to match name of file
public class Application {
    public static void main(String[] args) {
        Robot robot = new Robot(7);
        robot.start();

        // Notation for creating an object for the non-static inner class Brain outside of outer class Robot, since creating an instance of Robot does not automatically create an instance of Brain
        Robot.Brain brain = robot.new Brain();
        brain.think();

        // Notation for creating an object of static inner class Battery
        Robot.Battery battery = new Robot.Battery();
        battery.charge();
    }
}

Application.main(null);
Starting robot 7
Robot 7 is thinking
Robot's legs
Running
ID is: 7
My name is: Robert
Robot 7 is thinking
Battery charging...

43. Enum Types: Basic and Advanced Usage

Notes:

  • In Java, an enum is a special kind of class that contains a group of constant values (implicitly static and final). Enum stands for enumeration, which in turn means "specifically listed." Like attributes of a class, constant values of an enum can be accessed with the dot syntax, and the value name of a constant inside an enum is conventionally all upper case. Enum values have essentially their own data type (they are NOT Strings), and are represented by objects names similar to that of variable names; enum values are data type safe, as they are actually objects of the enum type they are declared within.
  • An enum type in Java is primarily used for storing a fixed group of already-defined constant values, which usually all have a common relation. An example of an enum set includes the days of the week, and the values of enums are typically checked with a switch statement. Enum values cannot be changed once the enum has been declared. An enum variable has the variable type of the enum type, can be used to store a particular enum value/object, and can subsequently access attributes and methods of that enum value using the dot notation.
  • An enum type can contain a constructor or multiple constructors, which can be used to initialize the instance data of an enum object. Since enum values are essentially objects of the enum type, when the list of enum values are declared within the enum, appropriate arguments must be passed for each of them (if the constructor(s) has parameters). An enum type/class can have attributes and methods like a normal class, which can be accessed with the dot notation on enum variables or enum values declared outside of the enum type (getters and setters needed for private variables), or directly from inside the enum type. All enum types have a values() method, which returns an array of all of the enum values/objects.

Examples:

// enum is a special kind of class
// Syntax for declaring an enum, with 3 certain values that it contains
enum Animal {
    // Enum values are objects of their enum type
    // Make sure to end list of enum values with semi-colon
    // The constructor of Animal has a parameter, so every enum value must be declared with an argument passed
    // When enum values are accessed outside of the enum type, arguments do not need to be passed, since the constructors are ran in the enum type itself
    CAT("Fergus"), DOG("Fido"), MOUSE("Jerry");

    // Instance data of enum type
    private String name;

    // Access modifier for enum type can either be private or package-private (default)
    Animal(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    // Override toString() method inherited from Object parent class
    @Override
    public String toString() {
        return "This animal's name is " + name;
    }

    // Method
    public void speak() {
        System.out.println("My name is " + name);
    }
}

public class Application {
    // Possible variables that the variable animal should be able to take on. This whole process is inefficient and can be faulty, which is why enums can be useful here
    public static final int DOG = 0;
    public static final int CAT = 1;
    public static final int MOUSE = 2;

    public static void main(String[] args) {
        int animal1 = CAT;

        // Switch statement, with the case values representing the values of certain final variables
        switch (animal1) {
            case DOG:
                System.out.println("Dog");
                break;
            case CAT:
                System.out.println("Cat");
                break;
            case MOUSE:
                System.out.println("Mouse");
                break;
        }

        // Syntax for setting a particular value of an enum type to an object enum variable. Notice how we need to define the enum type of the enum variable, similar to how we have to define the variable and object type when creating an ordinary object variable
        Animal animal2 = Animal.DOG;
        // Check the possible values of the enum variable
        switch(animal2) {
            case DOG:
                System.out.println("Dog");
                break;
            case CAT:
                System.out.println("Cat");
                break;
            case MOUSE:
                System.out.println("Mouse");
                break;
        }

        // Enum is a part of the java.lang package, which is automatically imported/available to Java compilers
        // Print out the enum value CAT of the enum type Animal
        System.out.println(Animal.CAT);
        // Get the class of the enum value DOG
        System.out.println(Animal.DOG.getClass());
        // Check if the enum value DOG is part of the enum class Animal
        System.out.println(Animal.DOG instanceof Animal);
        // Check if the enum value DOG is an instance of the Enum class
        // Objects of child classes count as instances of parent classes, since child classes extend from parent classes (e.g. object of Toyota child class is technically an instance of the Car parent class)
        System.out.println(Animal.DOG instanceof Enum);

        // Print toString() return value of MOUSE enum value/object
        System.out.println(Animal.MOUSE.toString());

        // Store enum object DOG (object constructor ran automatically in enum type itself) in an enum variable for further usage
        Animal animal3 = Animal.DOG;
        // Call method of enum type on enum variable
        animal3.speak();

        // name() method of Enum class that prints enum value/object name as a String
        System.out.println("Enum name as a string: " + Animal.DOG.name());

        // valueOf() method that calls String representation of a specified enum object name
        System.out.println(Animal.valueOf("CAT"));

        // values() method to get an array (stores data types of enum type) of the list of values/objects of a particular enum type
        // When the objects are printed out, the toString() method is automatically ran on each of them
        for (Animal animal : Animal.values()) {
            System.out.println(animal);
        }
    }
}

Application.main(null);
Cat
Dog
This animal's name is Fergus
class REPL.$JShell$14K$Animal
true
true
This animal's name is Jerry
My name is Fido
Enum name as a string: DOG
This animal's name is Fergus
This animal's name is Fergus
This animal's name is Fido
This animal's name is Jerry

44. Recursion

Notes:

  • Recursion is essentially the technique of commanding a method to call itself repeatedly until a halting/base condition is met (like a loop). Often times the halting condition checks the value of a particular variable(s), which is usually changed each time method calls itself, and often belongs to the parameter variable passed into the method. The primary purpose of recursive functions is too break complicated problems into simpler problems which can be solved more easily.
  • Stack memory is mostly used to store local variables and method calls (e.g. order of method execution), while heap memory is mostly used to store objects of classes, utilizing dynamic (allocating memory during runtime) memory allocation and de-allocation. A StackOverflowError can occur if a method calls itself in an infinite recursive loop.
  • A factorial integer is the product of an integer and all of the integers below it (e.g. 3! = 1 2 3 = 6).
  • Basically, the first call to a non-void recursive function will start a chain of return values, and each subsequent recursive call will become a part of the base return chain, as the return statement of a recursive call will usually perform some sort of operation on the original base return chain, then return a call of the recursive method again with some variables (typically parameters) changed, and connect that recursive call with the base return chain with the operation specified by the return statement (e.g. halting condition is value == 1, and return 1 when that happens --> return calculate(value - 1) value --> calculate(3) --> return calculate(2) 3 --> return calculate(1) 2 3 --> return 1 2 3 --> return 6). Once the halting condition has been reached, the recursive method will return a value that is usually combined with the rest of the return chain, stopping the calling of the method altogether, and computing and returning the final return value from the whole base return chain and its values (which can be visualized by a recursive tree).
  • For void recursive functions, the function will repeatedly call itself (without actually returning the calls) until the base condition has been met, in which case it will just return (returns no value, or void), putting a stop to the recursive calls. In each iteration, the program will perform some sort of action, allowing the recursive method to be one step closer to the halting condition. The halting/base condition is essential for the program to run effectively, as it prevents stack overflow, and allows the recursive method to actually stop calling itself.
  • The Towers of Hanoi is a popular recursive solution example.

Examples:

public class Application {
    // Private methods can only be accessed within the class
    // Static methods can be called without creating an object of the class (directly within the class, or dot notation with class name for outside the class)
    private static void test(int value) {
        value = value - 1;
        System.out.println(value);
    }

    private static void calculate(int value) {
        System.out.println(value);

        // Halting condition to stop recursive calls. Each time calculate is called, its parameter variable value is decremented by 1
        if (value == 1) {
            // Return keyword to stop the method execution and thus any more method calls
            return;
        }

        calculate(value - 1);
    }

    private static int factorial(int value) {
        System.out.println(value);

        // If value is equal to 1 in the latest recursive call, return the integer 1
        if (value == 1) {
            return 1;
        }

        // Else, return the current value multiplied by the return value of a new recursive call, which calls the factorial method again with value decremented by 1, and multiply all of this with the base return chain of values (the base return chain is appended to in every recursive call)
        return factorial(value - 1) * value;
    }

    public static void main(String[] args) {
        // value is of a primitive type, so its value here will not be affected by the operations performed in the method it is passed in as an argument
        // The method allocates new memory to its parameters, which means the values of its parameter variables are independent of the actual argument variables passed into it. This is an example of passing by value
        int value = 7;
        // The value variable inside the test() method is different from the value variable inside the main() tester method
        test(value);
        System.out.println(value);

        System.out.println("Example recursive function:");
        calculate(value);
        System.out.println("Factorial recursive calculator:");
        System.out.println(factorial(value));
    }
}

Application.main(null);
6
7
Example recursive function:
7
6
5
4
3
2
1
Factorial recursive calculator:
7
6
5
4
3
2
1
5040

45. Serialization: Saving Objects to Files

Notes:

  • Serialization is essentially the process of converting an object state (object's data) into a byte stream and saving that to a database, file, or over a network, while de-serialization is the process of converting an object byte stream into an actual object whose properties can be accessed in the program. Primitive types can also go through the same process of serialization and de-serialization.
  • Normally the file read program and file write program belong to the same program, and take on the forms of the open (read) and save (write) functions.
  • In Java, it is normal to have more than one main() method, as it allows the programming to have multiple entry points to their program, with each entry point serving a different purpose (e.g. run main function, test specific area of code, etc.).
  • Streaming in Java is basically sending an unchangeable, ordered sequence of elements, usually objects, to a specified location. We can perform different kinds of operations on sent streams (e.g. serializing, reading, writing, etc.).
  • The FileOutputStream class is primary used for writing data byte streams to a file, focusing on primitive data types like int, while FileWriter is used to write data byte streams for character-oriented data.
  • The ObjectOutputStream class essentially serializes Java objects into data byte streams, which are marked with the class name and object properties. These converted object data streams can be written to files and transferred over networks.
  • FileOutputStream and ObjectOutputStream both follow sequential file writing, meaning that the lines of data written into their files are done so by an ordered sequence of code between the opening and closing of the file.
  • In Java, to make objects of a class serializable, the class needs to implement the Serializable interface of the java.io package. The Serializable interface doesn't have any methods to implement, as it only really identifies classes that implement it as serializable.
  • Just to review, byte streams are used for the input and output of 8-bit byte data. Byte streams are sent to the program to be read from an input stream, and are sent to a file or database when written from an output stream, and they contain raw binary data (string of bits) which can be converted to multiple different data types.
  • FileInputStream is primarily used to reading raw byte data streams (such as image data or object data) from files to the program, while FileReader is more useful for reading character-oriented data. ObjectInputStream is used for de-serializing and reading object data previously written by ObjectOutputStream, converting the object data back into their corresponding objects to be accessed by the program.
  • It is important to note again that each time a certain FileOutputStream and ObjectOutputStream are ran, they don't build upon previously written data to files from previous runs. Instead, the files are reset to their default states at the start of the program (usually empty), and data is written to them from there (overwriting files).
  • serialVersionUID is usually a private, static, final, and long, and is a pre-defined unique identifier for a specific class. The purpose of this variable is to make sure that the class of a de-serialized object is the exact same as the class it was serialized as. The pre-defined serialVersionUID may be changed for a certain class by the programmer if the version of that class changes (i.e. attributes and methods are updated, so the programmer changes the serialVersionUID to make it so that the current version of the class is incompatible with previously serialized objects of an older version of the class) If serialVersionUID for an object of a particular class has somehow changed after de-serialization, the program will output an InvalidClassException runtime error.

Examples:

import java.io.FileOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.io.FileInputStream;
import java.io.ObjectInputStream;

// Person implements Serializable interface
class Person implements Serializable {
    // Example serialVersionUID
    // private static final long serialVersionUID = 7683485275814867L;

    private int id;
    private String name;

    public Person(int id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public String toString() {
        return "Person [id=" + id + ", name=" + name + "]";
    }
}

public class ReadObjects {
    public static void main(String[] args) {
        System.out.println("Reading objects...");

        // Try-with-resources to automatically close FileInputStream
        // FileInputStream to read bytes from file
        try (FileInputStream fi = new FileInputStream("/home/dylanluo05/DylanLuoAPCSA/assets/java-fundamentals-resources/people.bin")) {
            // ObjectInputStream used with FileInputStream to de-serialize and read byte streams of object data from the file
            ObjectInputStream is = new ObjectInputStream(fi);
            // De-serialize and read current object data stream from the input stream of the file. Each time a read() method of the input stream is called, the input stream moves on to the next byte stream to read
            // Downcast variable type of object back to Person, since the ObjectOutputStream saves objects as the parent variable type Object
            Person person1 = (Person) is.readObject();
            Person person2 = (Person) is.readObject();
            is.close();

            // Read objects in same order in which they were written
            System.out.println(person1);
            System.out.println(person2);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
            // Happens if de-serialized object's class could not be found in the program
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

class WriteObjects {
    public static void main(String[] args) {
        System.out.println("Writing objects...");
        Person mike = new Person(543, "Mike");
        Person sue = new Person(123, "Sue");

        System.out.println(mike);
        System.out.println(sue);

        // If people.bin does not already exist, FileOutStream will automatically create the file to the specified file path, or to the working directory if no path is specified
        // Try-with-resources notation with FileOutputStream as the resource, which automatically closes the files opened by FileOutputStream after all of their actions
        // FileOutputStream to enable to writing a data byte streams
        try (FileOutputStream fs = new FileOutputStream("/home/dylanluo05/DylanLuoAPCSA/assets/java-fundamentals-resources/people.bin")) {
            // ObjectOutputStream used in correspondence with FileOutputStream to serialize objects and write them as data byte streams to files
            ObjectOutputStream os = new ObjectOutputStream(fs);
            // Serialize and write object of Person to file
            os.writeObject(mike);
            os.writeObject(sue);
            // Manually close ObjectOutputStream, preventing memory leaks of the file
            os.close();
            // Happens if we can't create file to specified file location
        } catch (FileNotFoundException e) {
            e.printStackTrace();
            // Happens if we somehow can't write to the specified file
        } catch (IOException e) {
            e.printStackTrace();
        }

    }
}

WriteObjects.main(null);
ReadObjects.main(null);
Writing objects...
Person [id=543, name=Mike]
Person [id=123, name=Sue]
Reading objects...
Person [id=543, name=Mike]
Person [id=123, name=Sue]

46. Serializing Arrays

Notes:

  • In Java, an array is actually a type of object that is dynamically created (mutable), which means it is serializable as long as the objects within it are serializable. During serialization, the array will be serialized along with the objects within it.
  • An ArrayList is a generic class that is basically like a resizable array. Like an array, an ArrayList and its elements can also be serialized in the program.
  • Both arrays and ArrayList, when de-serialized, will take on their original form, as well as the objects within them. Like de-serializing objects, programmers are required to type cast the de-serialized arrays and ArrayLists, due to the Object Stream storing objects as the grandparent variable type Object (e.g. type[] for arrays, ArrayList<type> for ArrayLists).
  • Generic classes such as ArrayList encounter type erasure. Type erasure happens during compile time and is basically when the information about the parameterized type is lost during some kind of operation. Arrays, however, do not encounter type erasure, as they maintain the information about the variable type they store during compile time.
  • The purpose of the defined parameterized type is to prevent programmers from appending elements of the wrong variable type to the Generic, but after compile time, or in this case after serializing and de-serializing the Generic, the full information about the parameterized type is basically erased. Because of this, the program will produce a warning when you type cast a de-serialized Generic, but it will still work as long as the elements saved inside the Generic have the same variable type as the type casted parameterized type.

Examples:

import java.io.FileOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.io.FileInputStream;
import java.io.ObjectInputStream;

public class WriteObjects {
    public static void main(String[] args) {
        System.out.println("Writing objects...");

        // Instantiate array that stores variable type Person, which contains 3 objects of the Person class
        // The objects are not assigned to a variable but are rather instantiated directly in the array
        // Since the objects are not stored in variables, they can be considered anonymous objects, but since they are stored in an array, they can actually be referenced more than one time in the code with the array's methods, as well as take up individual heap memory
        // Even though the variable types of the objects are not defined, they will automatically be assigned variable type of the array, since they are references of the same class type as the variable type (would also work if they were references of a child class type of the variable type)
        Person[] people = {new Person(1, "Sue"), new Person(2, "Mike"), new Person(3, "Bob")};

        // ArrayList of parameterized variable type Person
        // Here, the asList() method of the Arrays class initializes the elements of the ArrayList by sort of converting an already-defined array into an ArrayList (the array itself doesn't change, but the ArrayList does)
        ArrayList<Person> peopleList = new ArrayList<Person>(Arrays.asList(people));

        // Notation for try-with-resources with multiple auto-closable resources defined within the try parameter
        try (FileOutputStream fs = new FileOutputStream("/home/dylanluo05/DylanLuoAPCSA/assets/java-fundamentals-resources/people-array.ser"); ObjectOutputStream os = new ObjectOutputStream(fs)) {
            // Serialize and write an array of Person objects to the file
            os.writeObject(people);
            // Serialize and write an ArrayList of Person objects to the file
            os.writeObject(peopleList);

            // Serialize and write an integer that is the size of the ArrayList into the file
            os.writeInt(peopleList.size());
            for (Person person : peopleList) {
                // Serialize and write each object in peopleList one at a time to the file
                os.writeObject(person);
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

// Implement Serializable interface to make class serializable by the program
class Person implements Serializable {
    private int id;
    private String name;

    public Person(int id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public String toString() {
        return "Person[id=" + id + ", name=" + name + "]";
    }
}

class ReadObjects {
    public static void main(String[] args) {
        System.out.println("Reading objects...");
        
        // Have try-with-resources auto-close both the FileInputStream and ObjectInputStream
        // This is so that the resources will be able to close no matter the outcome of the try-with-resources statement, as even if an exception is caught before the end of the resources' actions, they will auto-close at the end of the try-with-resources statement
        try (FileInputStream fi = new FileInputStream("/home/dylanluo05/DylanLuoAPCSA/assets/java-fundamentals-resources/people-array.ser"); ObjectInputStream is = new ObjectInputStream(fi);) {
            // Type cast de-serialized array back to variable type Person[], since the ObjectOutputStream stores objects as grandparent variable type Object, and store it inside a new initialized array of type Person called people, defining its size and the objects within it
            Person[] people = (Person[]) is.readObject();
            // Iterate through each Person object in the array and print out its String representation
            // Each object has been automatically assigned the variable type of the array
            for (Person person : people) {
                System.out.println(person);
            }
            // Type cast de-serialized ArrayList back to variable type ArrayList of parameterized type Person
            // Annotation for suppressing unchecked exception warnings
            @SuppressWarnings("unchecked")
            ArrayList<Person> peopleList = (ArrayList<Person>) is.readObject();
            // Iterate through ArrayList with enhanced for loop
            for (Person person : peopleList) {
                System.out.println(person);
            }
            // De-serialize integer that is the currently selected byte stream of the ObjectInputStream, and use that as the capacity of the for loop after, which iterates through the following de-serialized objects
            int num = is.readInt();
            for (int i = 0; i < num; i++) {
                // De-serialize current object
                Person person = (Person) is.readObject();
                // Print out object, which automatically calls the toString() method
                System.out.println(person);
            }

            is.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

WriteObjects.main(null);
ReadObjects.main(null);
Writing objects...
Reading objects...
Person[id=1, name=Sue]
Person[id=2, name=Mike]
Person[id=3, name=Bob]
Person[id=1, name=Sue]
Person[id=2, name=Mike]
Person[id=3, name=Bob]
Person[id=1, name=Sue]
Person[id=2, name=Mike]
Person[id=3, name=Bob]

47. The Transient Keyword and More Serialization

Notes:

  • In Java, a thread is essentially a directional path that is ran through during the program's execution. Most programs have at least one thread, known as the main thread, which allows for the program's execution. Having multiple threads helps the program to be more efficient, as it is able to perform many tasks at the same time. To achieve this, programmers often implement the Runnable or Callable interface into their classes.
  • The transient keyword defines a property of a class as un-serializable, meaning it will not be converted into a byte stream and will not be saved into a file, database, or over a network. An object will maintain the values of any transient variables before serialization, but after the process of serialization and de-serialization, its transient variables' values will be erased and instead have Java's respective data type default values.
  • In Java, all instance variables of a serializable class will be serialized, static variables will not be serialized and will default to the current value set by the class after de-serialization, transient variables will not be serialized, and super class variables will be serialized if the super class is serializable. It is important to note that during the de-serialization process, the program creates a new instance of the object in memory, but does not actually invoke the constructor call, as it instead bypasses it, populating the object's data with its original state and fields from the de-serialized information (with the exception of transient variables which are not serialized and static variables, which are given the current value set by the class itself).

Examples:

public class Person implements Serializable {
    private int id;
    private String name;
    // Make secondId variable transient, so that it cannot be serialized
    private transient int secondId;
    // Only one copy of static variables are created, and that copy is shared amongst multiple objects of the same class. This means whenever its value is changed, it will stay the value for all subsequent objects unless changed again
    private static int count;

    public Person() {
        System.out.println("Default constructor");
    }

    public Person(int id, String name, int secondId) {
        this.id = id;
        this.name = name;
        this.secondId = secondId;
        System.out.println("Normal constructor");
    }

    // Use static getters and setters to access a static attribute
    public static int getCount() {
        return count;
    }

    public static void setCount(int count) {
        // Static method notation, as Person refers to the same class reference as the this keyword
        // The this keyword usually refers to the object, but since we are working with a static variable, the class should be referred to instead
        Person.count = count;
    }

    @Override
    public String toString() {
        return "Person [id=" + id + ", name=" + name + ", secondId=" + secondId + ", count=" + count + "]";
    }
}

class WriteObjects {
    public static void main(String[] args) {
        System.out.println("Writing objects...");
        // Try-with-resources auto-closes FileOutputStream and ObjectOutputStream
        try (ObjectOutputStream os = new ObjectOutputStream(new FileOutputStream("/home/dylanluo05/DylanLuoAPCSA/assets/java-fundamentals-resources/transient.ser"))) {
            // Define transient variable secondId as 10 for person object before serialization
            // Will print Normal constructor, since we are using the three-argument constructor
            Person person = new Person(7, "Mark", 10);
            // Use dot notation to access setter method, but use the class name instead of object name, since the static method belongs to the class itself
            // The count static attribute will be set to 88 for all subsequent objects of the Person class, unless changed later on
            Person.setCount(88);
            System.out.println(person);
            os.writeObject(person);
        } catch (FileNotFoundException e) {
            // Print default error message for this kind of exception, and continue on with code
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

class ReadObjects {
    public static void main(String[] args) {
        System.out.println("Reading objects...");

        // Make code more concise by not storing the FileInputStream object in a variable, but instead instantiating it and passing it directly as an argument into ObjectInputStream
        // The FileInputStream object type is compatible to the FileInputStream variable type, and so is an appropriate argument for ObjectInputStream's parameter
        try (ObjectInputStream is = new ObjectInputStream(new FileInputStream("/home/dylanluo05/DylanLuoAPCSA/assets/java-fundamentals-resources/transient.ser"))) {
            // transient secondId was not serialized, so after de-serialization, the secondId attribute of the person object doesn't have an actual value, and so will default to a value of 0
            // Does not run constructor, since the fields of the object have already been previously defined, and are simply being de-serialized to be accessed
            // Default constructor nor Normal Constructor are printed, since no constructor's are actually invoked, as the program is restoring the object state from the serialized data
            Person person = (Person) is.readObject();
            // static variable count is not serialized, and so Java will give it the current static value set by the class
            System.out.println(person);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

WriteObjects.main(null);
ReadObjects.main(null);
Writing objects...
Normal constructor
Person [id=7, name=Mark, secondId=10, count=88]
Reading objects...
Person [id=7, name=Mark, secondId=0, count=88]

48. Passing by Value

Notes:

  • Java follows the system of passing by value, which often occurs in passing arguments to methods or constructors.
  • Passing by value for primitive data types means that whenever a value or variable value is passed as an argument to a method or constructor, the program creates a copy of that value for the parameter variable to store into new memory, instead of an actual reference to the primitive variable. This means that changes made to the parameter variable do not affect and are not connected to the argument variable. This also implies that the name of the argument variable doesn't really matter, as once it is passed to the method/constructor, its value will be copied to the parameter variable, and the program will perform operations on the parameter instead of the actual argument.
  • The normal scope of a variable is limited to the closest enclosing block, or curly parentheses, meaning it cannot be accessed outside of the block, but can be accessed and changed anywhere within the block (even within inner code blocks). This sort of explains the phenomenon of passing by value, since the argument value and parameter value are not even in the same code block.
  • Just a reminder that the Java compiler normally executes code in a sequence from the top to bottom of the program. Statements such as loops can loop the reading of the code back to a certain point of the program after certain code has been read, and will continue doing so until a halting condition has been met.
  • In Java, method overloading is where we create multiple methods of one class with the same name but with different parameters (can be different number of parameters or different data types of parameters). We can have 2 methods with the same name and parameters if they are from separate classes, since they are called/referenced by different objects (or classes if the methods are static) anyway.
  • When declaring primitive data in Java, the program allocates enough memory to store any value fits that data type (e.g. enough memory for maximum value of int). But when declaring non-primitive data, or objects, in Java, the program allocates memory for an address/reference to the actual memory of the object data.
  • Passing by value for non-primitive types is similar to that of passing by value for primitive types, but instead of the program creating a copy of the value itself, it creates a copy of the reference to the object passed. Because the parameter object variable refers to the same object as the argument object variable, changes made to the parameter object variable via the object's defined methods (or dot notation) will actually affect the argument object variable. This means that whenever the object is modified ANYWHERE in the program (usually by the object references that address it), the memory storing its data is actually modified.
  • However, if the parameter object reference is assigned to a new object, the parameter variable will now refer to a different area of memory, since the new keyword creates heap memory for a new object. This means that any subsequent changes to the object referenced by the parameter variable do not affect the object referenced by the argument variable, since the parameter and argument now refer to different objects.
  • Just to review, in Java, you cannot define a variable (meaning declaring variable type and name) more than once within the same code block, meaning every variable in a code block must have a unique variable name.

Examples:

class Person {
    private String name;

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "Person[name=" + name + "]";
    }
}

public class Application {
    public void show(int value) {
        // show() method creates a copy of the value passed into it for its parameter
        // So, the value parameter variable here is in a separate part of memory from the value variable passed as an argument
        // This value variable only exists within the show() method
        // Changes to value variable in this method do not affect the value variable in main() method
        System.out.println("Parameter value is initially: " + value);
        value++;
        System.out.println("Parameter value is now: " + value);
    }

    // Method overloading, where we create another show() method but with different parameters
    public void show(Person person) {
        System.out.println("Parameter object is initially: "+ person.toString());
        // Change the name attribute of the object currently referenced by both the parameter and the argument
        person.setName("John");
        // Set person object variable to a new object of Person with a different name attribute value
        // The parameter and argument now refer to different objects
        person = new Person("Mike");
        // The person object variable now does not affect the person object variable in the main() method, as the parameter person refers to a new object now
        person.setName("Tom");
        System.out.println("Parameter object is now: " + person.toString());
    }

    public void show(String text) {
        System.out.println("Parameter String is initially: " + text);
        // Changes like concatenation to a String variable will cause it to reference a new String object with new memory allocated toward it
        text += "!";
        System.out.println("Parameter String is now: " + text);
    }

    public void show(int[] nums) {
        System.out.print("Parameter array of integers before passing by value is: ");
        for (int num : nums) {
            System.out.print(num + " ");
        }
        System.out.println();
        // Use method of arrays to modify the object (array) referenced by both the parameter and the argument
        nums[2] = 7;
        // Make parameter variable reference new array object with different values
        // Parameter variable now refers to different array than argument variable
        // Have to define size first with new keyword, then set values, since nums originally already refers an array with defined values, which means it needs to first be assigned to a new object
        nums = new int[2];
        nums[0] = 1;
        nums[1] = 3;
        System.out.print("Parameter array of integers after passing by value is: ");
        for (int num : nums) {
            System.out.print(num + " ");
        }
        System.out.println();
    }

    public void show(String[] words) {
        System.out.print("Parameter array of Strings before passing by value is: ");
        for (String word : words) {
            System.out.print(word + " ");
        }
        System.out.println();
        // Modify array referenced by both parameter and argument
        words[2] = "Hey there";
        // The program will create new memory for the changed String, but the array itself will modify its own memory to store the new modified String
        words[2] += "!";
        System.out.print("Parameter array of Strings after passing by value is: ");
        for (String word : words) {
            System.out.print(word + " ");
        }
        System.out.println();
    }

    public static void main(String[] args) {
        // Make object name similar to class name as it is a common naming convention
        Application app = new Application();
        // This value variable only exists within the main() method
        int value = 7;
        // Passing by value for primitive types
        System.out.println("Original argument value before passing by value is: " + value);
        app.show(value);
        System.out.println("Original argument value after passing by value is: " + value);

        // Passing by value for non-primitive types (objects)
        // Print new empty line
        System.out.println();
        Person person = new Person("Bob");
        System.out.println("Argument object before passing by value is: " + person.toString());
        // Java knows which version of the show() method to invoke by reading the types of arguments passed into it and matching them with the appropriate parameters
        app.show(person);
        // Changes to the parameter object reference will affect the argument object reference if they continue to refer to the same object
        // If the parameter object reference is set to address a new object, it will no longer affect the object referred by the argument object reference
        System.out.println("Argument object after passing by value is: " + person.toString());
        System.out.println();

        // More non-primitive type passing by value examples
        // Passing by value for String
        String text = "Hey there";
        System.out.println("Argument String before passing by value is: " + text);
        app.show(text);
        System.out.println("Argument String after passing by value is: " + text);
        System.out.println();

        // Passing by value for array of primitive type
        // Can declare and define values for array on one line, since nums has not been assigned to an object yet, and since we are defining the variable type to be array of integers (int[]), we are showing the program that nums is a new variable being instantiated
        int[] nums = {1, 2, 3};
        System.out.print("Argument array of integers before passing by value is: ");
        for (int num : nums) {
            System.out.print(num + " ");
        }
        System.out.println();
        app.show(nums);
        System.out.print("Argument array of integers after passing by value is: ");
        for (int num : nums) {
            System.out.print(num + " ");
        }
        System.out.println();
        System.out.println();

        // Passing by value for array of non-primitive type
        String[] words = {"Hi", "Hello", "Hey"};
        System.out.print("Argument array of Strings before passing by value is: ");
        // Variables defined within parameters, including those of statements such as loops, are scoped to the code block following the declaration statement
        // This means that the parameters of loops are scoped to the code block of loops
        for (String word : words) {
            System.out.print(word + " ");
        }
        System.out.println();
        app.show(words);
        System.out.print("Argument array of Strings after passing by value is: ");
        for (String word : words) {
            System.out.print(word + " ");
        }
        System.out.println();
    }
}

Application.main(null);
Original argument value before passing by value is: 7
Parameter value is initially: 7
Parameter value is now: 8
Original argument value after passing by value is: 7

Argument object before passing by value is: Person[name=Bob]
Parameter object is initially: Person[name=Bob]
Parameter object is now: Person[name=Tom]
Argument object after passing by value is: Person[name=John]

Argument String before passing by value is: Hey there
Parameter String is initially: Hey there
Parameter String is now: Hey there!
Argument String after passing by value is: Hey there

Argument array of integers before passing by value is: 1 2 3 
Parameter array of integers before passing by value is: 1 2 3 
Parameter array of integers after passing by value is: 1 3 
Argument array of integers after passing by value is: 1 2 7 

Argument array of Strings before passing by value is: Hi Hello Hey 
Parameter array of Strings before passing by value is: Hi Hello Hey 
Parameter array of Strings after passing by value is: Hi Hello Hey there! 
Argument array of Strings after passing by value is: Hi Hello Hey there!