Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Understanding Classes in Java (Part 3)

DZone's Guide to

Understanding Classes in Java (Part 3)

This series continues with an advanced look at a basic premise: creating and building upon classes in Java while adhering to important OOP principles.

· Java Zone
Free Resource

Try Okta to add social login, MFA, and OpenID Connect support to your Java app in minutes. Create a free developer account today and never build auth again.

In the previous article, we explored the purpose and details of objects and how they make up the core execution structure of object-oriented programming. We also delved into the background history behind the invention of object-oriented programming and its correspondence to the programming languages we use today. In this article, we will get to the heart of the matter: Applying the concepts we learned in the previous articles to Java. At the completion of this article, we will have morphed our class specifications into Java classes and instantiated objects from these specifications. In order to make our way over these obstacles, we have to start small: The Java class.

A Class in Java

As we travel through the journey of modeling physical and abstract entities, we move from abstract concepts such as classes to more concrete ones, such as objects. Although objects represent a more material realization of a class, it still does not allow us to transform a specification into bits and bytes that are executed in a computer system. For that level of actualization, we need to introduce a programming language and a compiler.

A programming language is a formal language (in the same sense as a spoken language, with its own grammar) that allows us to express our ideas, such as classes, as human-readable source code that can then be transformed by a compiler to executable binary machine code. In the case of Java, there is an extra step between compilation and execution.

In particular, the Java programming language is an interpreted language that compiles the source code into bytecode, which is then interpreted by the Java Virtual Machine (JVM). Unlike native languages, such as C or C++, the source of Java is not compiled directly into machine code that can be executed by the machine on which it was compiled (or the machine for which it was cross-compiled). Instead, the Java compiler compiles Java source code into an intermediary machine code called bytecode that is executed by a virtual machine on the platform. This allows for Java code to be portable from one environment to another (or any environment for which there is a JVM implementation). For example, one bytecode file (called a .classfile) can be compiled on one machine and executed on another, since the native machine code of the second machine is abstracted by the JVM.

Although Java and the JVM are topics that can be discussed in volumes, it suffices to understand that Java is an object-oriented language that facilitates the execution of the class structures that we create. In the remainder of this section, we will transition our vehicle model from our custom specification notation to that of Java and actually make the vehicle do useful work. Traveling along this path, we will pay special attention to the similarities and idiosyncrasies of our existing class notation and the Java language notation, ensuring that we transfer our classes from a conceptual notation that does no actual work to a real language.

A Basic Class

The simple format for a class in Java should appear very familiar. For example, creating the most basic implementation of our Vehicleclass results in the following:

public class Vehicle {

}


There are two major distinctions that we must address:

  1. Classes in Java have visibility in the same manner as class state. For example, a public class can be used by any other class (Java classes may not be marked as private or protected). That begs the question: What else could it be but public? At this point, it is important to note that Java has an additional visibility modifier: The default modifier. This modifier, denoted by the lack of an explicit visibility modifier, allows any class (including itself) within the same package to access the class. A package can be thought of a group of related classes contained within the same namespace. As a corollary, the protected visibility modifier in Java also allows other classes in the same package to access the class, state, or behavior marked as protected. The complete list of visibility modifiers in Java and their corresponding visibility are enumerated in the table below:

    MODIFIER SELF PACKAGE SUBCLASS EVERYONE
    public

    Yes

    Yes

    Yes

    Yes

    protected

    Yes

    Yes

    Yes

    No

    Default (no modifier)

    Yes

    Yes

    No

    No

    private

    Yes

    No

    No

    No

  2. Scopes in Java are denoted by corresponding braces (e.g. { and }, respectively). In our custom notation, we used a colon-tab notation, where a tab showed correspondence between parts of our class that shared an affinity. For example, all state entries were tabled to the same level and the definition of behaviors was tabbed one level further than the declaration of the behavior. While this tabbing convention is commonly used in Java (and one that we will use throughout this article) and a mature programming practice, the scope of a class and the subparts of a class are strictly denoted by braces.

With our basic class definition complete, we can now add state to our class. In Java, each state entry is called an either an attribute, field, or instance variable. The format of fields is similar to that of our existing notation:

public class Vehicle {

    private String manufacturerName;
    private String modelName;
    private int productionYear;
    private Engine engine;
    private Transmission transmission;
    private int wheelCircumference;
}


Note that statements in Java are concluded with a semicolon (;) and no section delimiters (such as state: and behavior:) are needed. We also remove our existing Numbertypes for more specific types, such as int (integer) and double(a double-precision floating point, or decimal, value). In addition to our Vehicle class, we must now define our Engine and Tranission classes:

public class Engine {
    private int rpms;
}

public class Transmission {
    private double gearRatio;
}


With all of our required classes now created, we can add the behavior to each. In Java, behaviors are called methods, and each definition of a method, or method body, is enclosed in braces in the same manner as a class. By adding the methods to our classes, we obtain (note that any line that begins with // is a comment and not considered executable code):

class Vehicle { 

    private String manufacturerName;
    private String modelName;
    private int productionYear;
    private Engine engine;
    private Transmission transmission;
    private int wheelCircumference;

    public void accelerate(int amount) {
        // Change either engine RPM or transmission gear ratio
    }

    public void decelerate(int amount) {
        // Change either engine RPM or transmission gear ratio
    }

    public double getCurrentSpeed() {
        return this.transmission.getGearRatio() * this.engine.getRpms() * this.wheelCircumference;
    }
}

public class Engine {

    private int rpms;

    public int getRpms() {
        return this.rpms
    }

    public void increaseRpms(int amount) {
        this.rpms += amount;
    }

    public void decreaseRpms(int amount) {
        if (this.rpms - amount >= 0) {
            this.rpms -= amount;
        }
    }
}

public class Transmission {

    private double gearRatio;

    public double getGearRatio() {
        return this.gearRatio;
    }

    public void increaseGearRatio(double amount) {
        this.gearRatio += amount;
    }

    public void decreaseGearRatio(double amount) {
        this.gearRatio -= amount;
    }
}


Note that the method name and parameter list (the types and number of parameters) combine to make up the method signature. For example, the method signature for the acceleratemethod of our Vehicleclass is

accelerate(Number)


A Java class can have any number of methods (including zero), but the signature of two methods must not match. It is important to note that the name of the parameters does not factor into a method signature: Only the type and number of parameters, referred to as the parameter list. For example, accelerate(Number value) and accelerate(Number amount) have the same method signature, even though the name of the parameters do not match. If two methods have the same method name but differ in the number and/or type of its parameters, the two methods are said to be overloaded. For example, all of the methods in the following class are overloaded:

public class Greeter {  

    public void sayHi(String title, String firstName, String lastName) {
        System.out.println("Hello, " + title " " + firstName " " + lastName);
    }

    public void sayHi(String firstName, String lastName) {
        System.out.println("Hello, " + firstName + " " + lastName);
    }

    public void sayHi(String firstName) {
        System.out.println("Hello, " + firstName);
    }
}


Constructors

As in our original Vehicle class, we can also provide a constructor that is executed when an object of our Vehicleclass is created. The notation for a constructor is nearly identical to that of our original notation, where the name of the method matches the name of the class and the return type of the constructor is not specified:

class Vehicle { 

    private String manufacturerName;
    private String modelName;
    private int productionYear;
    private Engine engine;
    private Transmission transmission;
    private int wheelCircumference;

    public Vehicle(String manufacturerName, String modelName, int productionYear, int wheelCircumference) {
        this.manufacturerName = manufacturerName;
        this.modelName = modelName;
        this.productionYear = productionYear;
        this.engine = new Engine();
        this.transmission = new Transmission();
        this.wheelCircumference = wheelCircumference;
    }

    // Other methods removed for brevity
}


Within the body of our constructor, we instantiate both an Engine object and a Transmission object. Although we have not defined a constructor for either of these classes, the Java compiler automatically provides a default constructor, which accepts zero arguments and initializes all fields to their default values. When we explicitly create a constructor, we implicitly hide the default constructor provided by the compiler. Therefore, our Vehicle class no longer has a default constructor.

We can also define a constructor in terms of another constructor. For example, if we wanted to explicitly create a default constructor, we could do so without manually setting each of the fields of our class. Instead, we can defer to another constructor by using this to call another constructor:

class Vehicle { 

    private String manufacturerName;
    private String modelName;
    private int productionYear;
    private Engine engine;
    private Transmission transmission;
    private int wheelCircumference;

    public Vehicle() {
        this("default manufacturer", "default model", 2000, 100); 
    }

    public Vehicle(String manufacturerName, String modelName, int productionYear, int wheelCircumference) {
        this.manufacturerName = manufacturerName;
        this.modelName = modelName;
        this.productionYear = productionYear;
        this.engine = new Engine();
        this.transmission = new Transmission();
        this.wheelCircumference = wheelCircumference;
    }

    // Other methods removed for brevity
}


It is important to note that our delegation to another constructor using this the notation does not prohibit other logic from being included within the body of the constructor, but if other logic is included, this the call must be the first executable line of code (comments and whitespace are allowed before the call to another constructor).

An important note about Java methods and constructors must be made: Java does not include a mechanism for providing default parameters values. Instead, we can simply create a new constructor or method with fewer arguments and overload an existing constructor or method, delegating the body of the new method or constructor to an existing method or constructor, respectively. For example, if we wanted to our accelerate method to use 1 as its default parameter value (so that if no argument is supplied, it defaults to 1), we could create the following methods:

public class Vehicle {

    public void accelerate(int amount) {
        // Either increase the engine RPMs or reduce transmission gear ratio
    }

    public void accelerate() {
        this.accelerate(1);
    }
}


With constructors, methods, and fields under our belt, we are now prepared to move onto the more advanced features of Java classes, including inheritance.

Inheritance

Although we have a basic class definition for our vehicle, there are times when we need to explore the creation of inheritance hierarchies and the extension of classes. Just as with our previous class notation, Java includes a mechanism for creating new classes that extend other classes and conform to the is a relationship. For example, if we created a House class and wanted to create a specialized version of our original House, we can do so by using the extends keyword:

public class House {

    private String address;

    public House(String address) {
        this.address = address;
    }

    public void setAddress(String address) {
        this.address = address; 
    }

    public String getAddress() {
        return this.address;
    }
}

public class HouseWithDriveway extends House {

    private int numberOfCarsInDriveway;

    public HouseWithDriveway(String address, int numberOfCarsInDriveway) {
        super(address);
        this.numberOfCarsInDriveway = numberOfCarsInDriveway;
    }

    public boolean isDrivewayFull() {
        return this.numberOfCarsInDriveway >= 4;
    }
}


In this example, we have simply added more fields and more methods to our original House class. In general, when we create classes, we should create complete, self-contained units that allow for extension if new behaviors or more fields are needed. This maxim is codified in the Open/Closed Principle:

Software classes should be open for extension, but closed for modification.

In essence, if we need to add more behavior or state to a system, we should not need to modify existing code, but rather, we should be able to create new classes that still act as the original classes (using the is a relationship). This is a form of relationship is formalized by the Liskov Substitution Principle, which states

Subtype Requirement: Let p( x) be a property provable about objects x of type T. Then p( y) should be true for objects y of type S where S is a subtype of T.

In short, if some external entity, such as a class or method, expects an object of type T, then supplying an object of type S, where S is a subtype of T, should ensure that all requirements of T are met. In essence, S should be interchangeable with T. This is a very powerful principle in object-oriented programming and allows for some very clever techniques. We will address these techniques in a later article when we explore polymorphism and dependency injection.

Before moving on, we must address the constructor of our HouseWithDriveway class. Within the constructor of a subclass, the constructor of the superclass must be executed. In cases where the superclass has a default constructor, this happens implicitly and the subclass need not directly call the superclass constructor. In the case of our House class, we have not supplied a default constructor (and the compiler-generated one has been hidden since we created an explicit constructor); therefore, we must explicitly call the superclass constructor.

To do this, we use the super keyword, supplying the arguments to the superclass constructor. In the case of our HouseWithDriveway, we pass along the address. It is important to note that superclass constructor is executed and completes prior to the execution of the subclass constructor (the superclass portion of the subclass must be initialized first). Once the superclass constructor completes its execution, the subclass constructor completes and the object is fully constructed.

If the superclass extends another class, the upper-most constructor completes first, then its subclass, working down the class hierarchy until the lowest subclass is constructed. In Java, if no superclass is explicitly extended, a class implicitly extends the Object class. Therefore, when a class without an explicit superclass is constructed, the default constructor for the Object class is implicitly called (this constructor is explicitly included in the stack trace when an error occurs, as demonstrated here).

Although our House subclass did not override any behavior in the original House class, a subclass may provide its own method implementations (override) for methods that exist in the superclass. For example, we could conceive of a class for a TownhouseUnit that has the following definition:

public class TownhouseUnit extends House {

    private String unit;

    public TownhouseUnit(String address, String unit) {
        super(address);
        this.unit = unit;
    }

    @Override
    public String getAddress() {
        return super.getAddress() + " Unit " + this.unit;
    }
}


In our TownhouseUnit subclass, we want the unit to be added to the address, and thus we override the original getAddress method to include this unit designation at the end. Notice that we can explicitly call the getAddress method of the superclass (House) by using the keywordsuper, which is similar to the this keyword, but instead of representing the current object, it represents the current object of the superclass. For example, the type of super in TownhouseUnit is House. By explicitly calling the getAddress of the superclass, we are able to add functionality by incorporating existing functionality.

It is also important to note that we also included the @Overridea annotation about the method that we overrode. This is an added checked that allows the compiler to make sure that our overridden method does, in fact, override an existing method in the superclass. If it does not, the compiler will throw an error during compilation. Therefore, although it is not required that an overriding method include the @Override annotation, it is highly suggested that it be included (see Predefined Annotation Types for more information).

An important note must be made about extending classes: Due to the diamond problem, only one class can be extended at a time. This is not necessarily true for all specializations of inheritance, as we will see in the following sections.

Interfaces

Interfaces are at the core of any object-oriented language, including Java. They represent the windows through which we voluntarily allow the outside world see with our classes. Just as in our previous notation, an interface is composed of method declarations with no associated body or fields. For example, if we wish to create an interface for an animal, we could create the following:

public interface Animal {
    public int getAge();
    public String makeNoise();
}


Although we have now created a proper specification for an animal, it is unable to perform useful work because it cannot be instantiated as an object (and therefore, cannot exist during runtime). In order to make our Animal interface useful, we need to create an implementation class, or concrete class, that implements our interface. For example, if we wanted to create a dog, we could create the following class:

public class Dog implements Animal {

    private int yearsOld;

    public Dog(int yearsOld) {
        this.yearsOld = yearsOld;
    }

    @Override
    public int getAge() {
        return this.yearsOld * 7;
    }

    @Override
    public String makeNoise() {
        return “Woof”;
    }
}


Notice that our Dog class has all of the same methods (at least the same, but it could have more) as our Animal interface, and therefore, can be treated as an Animal. Intuitively, interfaces are the bare minimum specification of an entity or concept. They force a dependent class to only interact through the declared methods without any knowledge of the implementation of any methods or fields.

Stated differently, some class that depends on the Animal interface is restricted to interacting with it through the two methods that it declares. It has no idea about the behavior that will happen when it calls the methods or any state that is being used to support these methods: All it knows is that when it calls getAge, an integer will be returned that represents its age. This is of paramount importance when dealing with large systems with hundreds or even thousands of classes, and many more dependencies between those classes.

It should be noted that the default visibility for all methods in an interface is public. Also note that an interface method cannot be marked as private, since the interface represents the externally visible portion of a class (Java 9 will allow for private interface methods, but unless absolutely needed for a specific situation, they should be avoided in general use). Therefore, if we remove the visibility modifiers for our methods in the Animal interface, our interface would be functionally equivalent to our existing interface definition. For example, we could have defined our Animal interface as follows and it would have been functionally equivalent to our first definition:

public interface Animal {
    int getAge();
    String makeNoise();
}


It is also important to note that just as with the previous section, the @Override annotation is not required, but it is highly suggested. Using the annotation allows us to ensure that if the Animal interface ever changes (for example, if we remove the getAge method), our Dog class will fail to compile since we have told the compiler that we expect the getAge method to override the getAge method in the Animal interface (which no longer exists).

Since we do not provide any implementation details in an interface, a class can implement as many interfaces as desired, providing each interface name in a common-separated list. For example, our Dog class could have implemented multiple interfaces:

public interface FurryThing {
    public void pet();
}

public class Dog implements Animal, FurryThing {

    private int yearsOld;

    public Dog(int yearsOld) {
        this.yearsOld = yearsOld;
    }

    @Override
    public int getAge() {
        return this.yearsOld * 7;
    }

    @Override
    public String makeNoise() {
        return “Woof”;
    }

    @Override
    public void pet() {
        // Make the dog smile
    }
}


Abstract Classes

A close cousin to the interface is the abstract class. The major difference between interfaces and abstract classes is that abstract classes are allowed to have fields and method bodies. Now, this may sound like identical to a class, but there are some important distinctions. Abstract classes may mark a method as abstract, which makes it equivalent to an interface method and abstract classes may not be instantiated (similar to interfaces).

This is common when creating a class structure using the Template Pattern. In a template class, we know the general order of how we want to call our methods, but we do not know what should be performed in each. For example:

public abstract class Packer {

    private Item item;

    public Packer(Item item) {
        this.item = item;
    }

    public void pack() {
        this.insertIntoPackage(this.item);
        this.closePackage();
        this.handOffPackage();
    }

    protected abstract void insertIntoPackage(Item item);
    protected abstract void closePackage();
    protected abstract void handOffPackage();
}


Notice that we set the visibility of our abstract methods to protected, since we expect a concrete class to override these methods. If we wished to them to be callable by outside classes, we could have set them to public. Just as with interfaces, the default visibility of abstract methods is public and private abstract methods are prohibited.

In order for our Packer abstract class to do some useful work, we must create a concrete implementation. Implementations of an abstract class use the same notation as extending a normal class. For example, we could create a UpsPacker with the following definition:

public class UpsPacker extends Packer {

    private UpsAirport airport;
    private Package shippablePackage;

    public UpsPacker(Item item, UpsAirport airport) {
        super(item);
        this.airport = airport;
        this.shippablePackage = new Packge();
    }

    @Override
    protected void insertIntoPackage(Item item) {
        // Do some UPS-specific packing
    }

    @Override
    protected void closePackage() {
        // Close the package in a UPS-specific manner
    }

    @Override
    protected void handOffPackage() {
        this.airport.shipPackage(this.shippablePackage)
    }
}


Since abstract classes use the extends technique for providing implementation details, a class can only extend a single abstract base class (as is the case with normal classes).

Instantiation

The last topic we must address is that of instantiating objects. The notation that Java uses for this task is very similar to our previous class notation, where the new keyword is used and the instantiated object is assigned to a variable with a defined type, as follows:

Vehicle myVehicle = new Vehicle("Ford", "F150", 2017, 113);


Notice that we may call the constructor with arguments that map to the parameters defined for the constructors available for the class. With our new object instantiated, we can now call the methods of the object (the ones that are visible) in exactly the same manner as we did in our original notation:

myVehicle.accelerate(10);


One major distinction between primitive types and the classes we create is that primitive types do not require the use of the new keyword to instantiate objects. For example, we can assign the value of an integer without using the new keyword:

int myInteger = 17;


In the same manner, all other primitive types can be instantiated without the new keyword:

String myName = "Justin";
char someChar = 'c';
double someDecimal = 2.54;
boolean someFlag = false;


With the new keyword, we have achieved the ability to not only create classes but to also instantiate objects of those classes and do useful work. Although classes and objects in Java may seem disappointingly simple, their power does not come from their definitions, but rather, from their relationships with one another. It is through the associations and dependencies that we create where most of the heavy lifting in Java is accomplished.

Conclusion

Revisiting the goals we set at the beginning of this article, we are now capable of not only creating Java classes with the desired fields, methods, and visibility, but we are now also capable of creating interfaces and abstract classes. We are also adept to define constructors and instantiating objects to do useful work.

Although we now have an understanding of how to create a class and instantiate an object in Java, we have still only achieved a novice level of understanding. While these simple tasks are foundational in creating sound software, large-scale systems require more than just a basic understanding. For those impressive systems, we need to explore more of the pragmatic, advanced techniques used in the Java ecosystem. In the next article, we will dive into two of the essential techniques in a Java programmer's toolbox: Polymorphism and dependency injection.

Next article: Understanding Classes in Java (Part 4)

Build and launch faster with Okta’s user management API. Register today for the free forever developer edition!

Topics:
java ,java classes ,inheritance ,oop ,tutorial

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}