Object-oriented programming (OOP) is sometimes portrayed as difficult and intimidating. The truth is that object-oriented programming uses a very familiar model to help make programs easier to manage. Let’s take another look to see how easy it is to understand this very popular and influential programming style.
Objects are familiar
In everyday life, we interact with objects in the world. Moreover, we recognize individual objects as having defining characteristics. The dog on the couch has attributes such as a color and a breed. In programming, we call these attributes properties. Here’s how we would create an object to represent a dog and its color and breed in JavaScript:
let dog = {
color: “cream”,
breed: “shih tzu”
}
The dog
variable is an object with two properties, color
and breed
. Already we are in the realm of object-oriented programming. We can get at a property in JavaScript using the dot operator: dog.color
.
Creating classes of objects
We’ll revisit encapsulation in a stronger form shortly. For now, let’s think about the limitations of our dog
object. The biggest problem we face is that any time we want to make a new dog
object, we have to write a new dog
variable. Often, we need to create many objects of the same kind. In JavaScript and many other programming languages, we can use a class for this purpose. Here’s how to create a Dog
class in JavaScript:
class Dog {
color;
breed;
}
The class
keyword means “a class of objects.” Each class instance is an object. The class defines the generic characteristics that all its instances will have. In JavaScript, we could create an instance from the Dog
class and use its properties like this:
let suki = new Dog();
suki.color = "cream"
console.log(suki.color); // outputs “cream”
Classes are the most common way to define object types, and most languages that use objects—including Java, Python, and C++—support classes with a similar syntax. (JavaScript also uses prototypes, which is a different style.) By convention, the first letter of a class name is capitalized, whereas object instances are lowercased.
Notice the Dog
class is called with the new
keyword and as a function to get a new object. We call the objects created this way “instances” of the class. The suki
object is an instance of the Dog
class.
Adding behavior
So far, the Dog
class is useful for keeping all our properties together, which is an example of encapsulation. The class is also easy to pass around, and we can use it to make many objects with similar properties (members). But what if we now want our objects to do something? Suppose we want to allow the Dog
instances to speak. In this case, we add a function to the class:
class Dog {
color;
breed;
speak() {
console.log(`Barks!`);
}
Now the instances of Dog
, when created, will have a function that can be accessed with the dot operator:
set suki = new Dog();
suki.speak() // outputs “Suki barks!”
State and behavior
In object-oriented programming, we sometimes describe objects as having state and behavior. These are the object’s members and methods. It’s part of the useful organization that objects give us. We can think about the objects in isolation, as to their internal state and behavior, and then we can think about them in the context of the larger program, while keeping the two separate.
Private and public methods
So far, we’ve been using what are called public members and methods. That just means that code outside the object can directly access them using the dot operator. Object-oriented programming gives us modifiers, which control the visibility of members and methods.
In some languages, like Java, we have modifiers such as private
and public
. A private
member or method is only visible to the other methods on the object. A public
member or method is visible to the outside. (There is also a protected
modifier, which is visible to the parts of the same package.)
For a long time, JavaScript only had public
members and methods (although clever coders created workarounds). But the language now has the ability to define private access, using the hashtag symbol:
class Dog {
#color;
#breed;
speak() {
console.log(`Barks!`);
}
}
Now if you try to access the suki.color
property directly, it won’t work. This privacy makes encapsulation stronger (that is, it reduces the amount of information available between different parts of the program).
Getters and setters
Since members are usually made private in object-oriented programming, you will often see public methods that get and set variables:
class Dog {
#color;
#breed;
get color() {
return this.#color;
}
set color(newColor) {
this.#color = newColor;
}
}
Here we have provided a getter and a setter for the color
property. So, we can now enter suki.getColor()
to access the color. This preserves the privacy of the variable while still allowing access to it. In the long term, this can help keep code structures cleaner. (Note that getters and setters are also called accessors and mutators.)
Constructors
Another common feature of object-oriented programming classes is the constructor. You notice when we create a new object, we call new
and then the class like a function: new Dog()
. The new
keyword creates a new object and the Dog()
call is actually calling a special method called the constructor. In this case, we are calling the default constructor, which does nothing. We can provide a constructor like so:
class Dog {
constructor(color, breed) {
this.#color = color;
this.#breed = breed;
}
let suki = new Dog(“cream”, “Shih Tzu”);
Adding the constructor allows us to create objects with values already set. In TypeScript, the constructor is named constructor
. In Java and JavaScript, it’s a function with the same name as the class. In Python, it’s the __init__
function.
Using private members
Also note that we can use private members inside the class with other methods besides getters and setters:
class Dog {
// ... same
speak() {
console.log(`The ${breed} Barks!`);
}
}
let suki = new Dog(“cream”, “Shih Tzu”);
suki.speak(); // Outputs “The Shih Tzu Barks!”
OOP in three languages
One of the great things about object-oriented programming is that it translates across languages. Often, the syntax is quite similar. Just to prove it, here’s our Dog
example in TypeScript, Java, and Python:
// Typescript
class Dog {
private breed: string;
constructor(breed: string) {
this.breed = breed;
}
speak() { console.log(`The ${this.breed} barks!`); }
}
let suki = new Dog("Shih Tzu");
suki.speak(); // Outputs "The Shih Tzu barks!"
// Java
public class Dog {
private String breed;
public Dog(String breed) {
this.breed = breed;
}
public void speak() {
System.out.println("The " + breed + " barks!");
}
public static void main(String[] args) {
Dog suki = new Dog("cream", "Shih Tzu");
suki.speak(); // Outputs "The Shih Tzu barks!"
}
}
// Python
class Dog:
def __init__(self, breed: str):
self.breed = breed
def speak(self):
print(f"The {self.breed} barks!")
suki = Dog("Shih Tzu")
suki.speak()
The syntax may be unfamiliar, but using objects as a conceptual framework helps make the structure of almost any object-oriented programming language clear.
Supertypes and inheritance
The Dog
class lets us make as many object instances as we want. Sometimes, we want to create many instances that are the same in some ways but differ in others. For this, we can use supertypes. In class-based object-oriented programming, a supertype is a class that another class descends from. In OOP-speak, we say the subclass inherits from the superclass. We also say that one class extends another.
JavaScript doesn’t (yet) support class-based inheritance, but TypeScript does, so let’s look at an example in TypeScript.
Let’s say we want to have an Animal
superclass with two subclasses defined, Dog
and Cat
. These classes are similar in having the breed
property, but the speak()
method is different because the classes have different speak behavior:
// Animal superclass
class Animal {
private breed: string;
constructor(breed: string) {
this.breed = breed;
}
// Common method for all animals
speak() {
console.log(`The ${this.breed} makes a sound.`);
}
}
// Dog subclass
class Dog extends Animal {
constructor(breed: string) {
super(breed); // Call the superclass constructor
}
// Override the speak method for dogs
speak() {
console.log(`The ${this.breed} barks!`);
}
}
// Cat subclass
class Cat extends Animal {
constructor(breed: string) {
super(breed); // Call the superclass constructor
}
// Override the speak method for cats
speak() {
console.log(`The ${this.breed} meows!`);
}
}
// Create instances of Dog and Cat
const suki = new Dog("Shih Tzu");
const whiskers = new Cat("Siamese");
// Call the speak method for each instance
suki.speak(); // Outputs "The Shih Tzu barks!"
whiskers.speak(); // Outputs "The Siamese meows!"
Simple! Inheritance just means that a type has all the properties of the one it extends from, except where I define something differently.
In object-oriented programming, we sometimes say that when type A extends type B, that type A is-a type B. (More about this in a moment.)
Inheritance concepts: Overriding, overloading, and polymorphism
In this example, we’ve defined two new speak()
methods. This is called overriding a method. You override a superclass’s property with a subclass property of the same name. (In some languages, you can also overload methods, by having the same name with different arguments. Method overriding and overloading are different, but they are sometimes confused because the names are similar.)
This example also demonstrates polymorphism, which is one of the more complex concepts in object-oriented programming. Essentially, polymorphism means that a subtype can have different behavior, but still be treated the same insofar as it conforms to its supertype.
Say we have a function that uses an Animal
reference, then we can pass a subtype (like Cat
or Dog
) to the function. This opens up possibilities for making more generic code.
function talkToPet(pet: Animal) {
pet.speak(); // This will work because speak() is defined in the Animal class
}
Polymorphism literally means “many forms.”
Abstract types
We can take the idea of supertypes further by using abstract types. Here, abstract just means that a type doesn’t implement all of its methods, it defines their signature but leaves the actual work to the subclasses. Abstract types are contrasted with concrete types. All the types we’ve seen so far were concrete classes.
Here’s an abstract version of the Animal
class (TypeScript):
abstract class Animal {
private breed: string;
abstract speak(): void;
}
Besides the abstract
keyword, you’ll notice that the abstract speak()
method is not implemented. It defines what arguments it takes (none) and its return value (void
). For this reason, you can’t instantiate abstract classes. You can create references to them or extend them—that’s it.
Also note that our abstract Animal
class doesn’t implement speak()
, but it does define the breed
property. Therefore, the subclasses of Animal
can access the breed
property with the super
keyword, which works like the this
keyword, but for the parent class.
Interfaces
In general, an abstract class lets you mix concrete and abstract properties. We can take that abstractness even further by defining an interface. An interface has no concrete implementation at all, only definitions. Here’s an example in TypeScript:
interface Animal {
breed: string;
speak(): void;
}
Notice that the property and method on this interface don’t declare the abstract
keyword—we know they are abstract because they are part of an interface.
Abstract types and overengineering
The ideal of abstract types is to push as much as you can into the supertypes, which supports code reuse. Ideally, we could define hierarchies that naturally contain the most general parts of a model in the higher types, and only gradually define specifics in the lower. (You can get a sense of this in Java and JavaScript’s Object
class, from which all others descend and which defines a generic toString()
method.)
This story originally Appeared on Infoworld