深入理解JavaScript中的面向对象(一)

Heero.Luo发表于9年前,已被查看3158次

在学习JavaScript的过程中,面向对象是必须要学会的课题。然而,网络上大部分文章只是简单地讲述如何通过代码实现面向对象,忽略了理论和原理方面的知识。本系列文章将从头开始逐步讲解面向对象编程思想及其在JavaScript中的实现与应用。

从一个简单的需求开始

假设我们需要在程序中计算各种形状的周长,代码可能是这样的:

var rectangle = {
	name: '长方形1',
	type: 'rectangle',
	length: 5,  // 长
	width: 10   // 宽
};
var square = {
	name: '正方形1',
	type: 'square',
	length: 5  // 边长
};
var circle = {
	name: '圆形1',
	type: 'circle',
	radius: 5  // 半径
};

function computePerimeter(shape) {
	var result;
	switch (shape.type) {
        case 'rectangle':
        	// 矩形周长:(长+宽)*2
        	result = (shape.length + shape.width) * 2;
        	break;
        case 'square':
        	// 正方形周长:边长*4
        	result = shape.length * 4;
        	break;
        case 'circle':
        	// 圆形周长:2*PI*半径
        	result = 2 * Math.PI * shape.radius;
        	break;
    }

    return shape.name + '的周长是' + result;
}

但这种实现方式有着不少缺陷:

  • 要增加更多形状的周长计算时,必须改动computePerimeter函数,可扩展性差;
  • 算法集中在computePerimeter函数,随着形状越来越多,这个函数会越来越庞大,最终变得难以维护;
  • computePerimeter通过shape.type判定形状类型,但如果type属性值错误(如拼错或大小写错误),就无法计算出正确的周长值。

如何解决以上缺陷呢?带着这个问题,我们开始学习面向对象。

类和对象

类是对具有相似特征事物的统称,例如“电脑”是对世界上所有电脑的统称;而对象则是某个类的具体事物,例如世界上所有的电脑都是电脑对象,又叫做电脑类的实例。

类与类之间最常见的关系是继承,其中被继承的类称为父类,继承而来的类称为子类;子类具有父类的所有特征。例如平板电脑都是电脑的子类,iPad是平板电脑的子类。在继承的层次关系中,位于最上层的又被成为基类,例如电脑是iPad的基类。

具体到编程语言中,类实质上是一种数据类型。例如在JavaScript中,所有字符串都是String类的实例,所有数组都是Array类的实例,所有类型的基类都是Object。

C#中的面向对象

JavaScript并不是一门面向对象的语言,为了便于描述和理解各种概念,下面先通过C#来学习。

类的声明

C#中通过关键字class来声明类,例如Square类(正方形):

class Square
{
	public string Name;
	public double Length;
	public Square()
	{
		this.Length = 1;
	}
	public double Perimeter()
	{
		return this.Length * 4;
	}
}

类可以有属性方法。上面代码中的Name和Length为属性,分别是字符串和浮点数类型;而Perimeter则为方法,它没有参数,返回值是浮点数类型。声明属性和方法时还可以加上访问修饰符:public、private或protected(用处后面再详细介绍)。类中还有一个名字跟类名一样的特殊函数,即构造函数,这个函数在创建实例时自动调用。

声明类之后就可以通过new操作符创建对象:

Square square = new Square();
square.Perimeter(); // 构造函数中给Length属性赋值为1,因此结果为4
square.Length = 10;
square.Perimeter(); // 给Length属性赋值为10,因此结果为40

接下来声明矩形类和圆形类:

class Rectangle
{
	public string Name;
	public double Length;
	public double Width;
	public double Perimeter()
	{
		return (this.Length + this.Width) * 2;
	}
}

class Circle
{
	public string Name;
	public double Radius;
	public double Perimeter()
	{
		return 2 * Math.PI * Radius;
	}
}

继承

仔细观察可以发现:Square、Rectangle和Circle存在一些共同点:

  • 它们都有一个字符串类型的属性Name;
  • 它们都有一个返回值类型为double、没有任何参数的方法Perimeter。

为了减少代码重复,可以进一步抽象出一个Shape类(形状),然后让Square、Rectangle和Circle都继承于它。然而,声明Shape类的时候,问题来了。形状类不能表示任何一种具体的形状,也就无法知道如何计算周长,那Perimeter方法应该如何实现呢?

class Shape
{
	public string Name;
	public double Perimeter()
	{
		// do what?
	}
}

答案是不实现。在面向对象的概念中,所有对象都是通过类来描绘的,但是反过来,并不是所有的类都是用来描绘对象的。某些类中没有包含足够的信息来描绘具体的对象,也就无法实例化,只能作为基类存在,这样的类就是抽象类。而Shape正是这样一个类:

abstract class Shape
{
	public string Name;
	public abstract double Perimeter();
}

抽象类通过abstract关键字声明,可以包含抽象方法。抽象方法只有方法声明,没有具体实现,它存在的意义是,强制子类必须存在这样一个方法并且实现它

有了Shape这个父类,Square、Rectangle、Circle可以重写为:

class Square : Shape
{
	public double Length;
	public override double Perimeter()
	{
		return this.Length * 4;
	}
}

class Rectangle : Shape
{
	public double Length;
	public double Width;
	public override double Perimeter()
	{
		return (this.Length + this.Width) * 2;
	}
}

class Circle : Shape
{
	public double Radius;
	public override double Perimeter()
	{
		return 2 * Math.PI * Radius;
	}
}

C#中通过冒号来表示继承,冒号后面的为父类。由于Shape类的三个子类都继承了Name这个属性,所以不需要再声明一次了。它们又都实现了Perimeter方法,但实现各不相同,这就是面向对象中的多态——一种定义,多种实现

多态还有一种重要的作用——把不同的子类对象当作父类对象来处理,屏蔽不同子类对象之间的差异。也就是说可以把变量声明为父类类型,却实例化为子类对象,例如:

Shape shape = new Circle();

不过要注意,此时shape变量仍然是Shape类型,而非Circle类型,所以不能访问它的Radius属性:

Shape shape = new Circle();
shape.Radius = 10; // 编译失败

但这样写是没有问题的:

Circle circle = new Circle();
circle.Radius = 10;
Shape shape = circle;

封装

先来看看以下代码有什么问题:

Square square = new Square();
square.Length = -10;

很明显,正方形的边长不可能为负数。然而,给Length赋值为负数却是合法的。为了阻止这种非法值,面向对象提供了封装这一特性。

封装是通过访问修饰符来实现的:

  • public:在类的内部及外部均可访问;
  • private:在类的内部可以访问;
  • protected:在类及其子类的内部可以访问。

假如把Square类Length属性的修饰符改成private,它就不能被外部访问了(无论是读还是写):

class Square
{
	private double Length;
	// 其他照抄
}
Square square = new Square();
double length = square.Length; // 读操作,编译失败
square.Length = 10;  // 写操作,编译失败

那么问题来了,如果要访问Length的值,该怎么办?答案就是通过public的方法:

class Square
{
	private double Length;
	public double GetLength()
	{
		return this.Length;
	}
	public void SetLength(double length)
	{
		if (length < 0) { throw new Exception("…"); }
		this.Length = length;
	}
	public double Perimeter()
	{
		return this.Length * 4;
	}
}

Square square = new Square();
square.SetLength(10);

虽然Length属性是private的,但GetLength和SetLength方法是public的,通过调用它们就间接获取或修改Length的值。并且,SetLength对传入的参数进行了判断,如果是非法值就抛出异常,这样就保证了值是合法的。

解决问题

掌握了以上知识,开头提到的缺陷就可以解决了。结合继承、多态、封装的特性,先编写几种形状类:

// 形状
abstract class Shape
{
	protected string Name;
	public string GetName() { return this.Name; }
	public void SetName(string name) { this.Name = name; }

	public abstract double Perimeter();
}

// 正方形
class Square
{
	private double Length;
	public double GetLength() { return this.Length; }
	public void SetLength(double length)
	{
		if (length < 0) { throw new Exception("…"); }
		this.Length = length;
	}

	public double Perimeter()
	{
		return this.Length * 4;
	}
}

// 长方形
class Rectangle : Shape
{
	private double Length;
	public double GetLength() { return this.Length; }
	public void SetLength(double length)
	{
		if (length < 0) { throw new Exception("…"); }
		this.Length = length;
	}

	private double Width;
	public double GetWidth() { return this.Width; }
	public void SetWidth(double width)
	{
		if (width < 0) { throw new Exception("…"); }
		this.Width = width;
	}

	public double Perimeter()
	{
		return (this.Length + this.Width) * 2;
	}
}

// 圆形
class Circle : Shape
{
	private double Radius;
	public double GetRadius() { return this.Radius; }
	public void SetRadius(double radius)
	{
		if (radius < 0) { throw new Exception("…"); }
		this.Radius = radius;
	}

	public double Perimeter()
	{
		return 2 * Math.PI * Radius;
	}
}

接着编写ComputePerimeter函数并调用:

string ComputePerimeter(Shape shape)
{
	return shape.GetName() + '的周长是' + shape.Perimeter();
}

Square square = new Square();
square.SetLength(5);
ComputePerimeter(square);

Circle circle = new Circle();
circle.setRadius(5);
ComputePerimeter(circle);

多态的作用在此刻显现:

  • 由于可以把子类对象当做父类对象来处理,所以square和circle都可以作为Shape对象传入为ComputePerimeter的参数;
  • GetName和Perimeter都是Shape类的方法,所以调用它们也不会有任何问题;
  • 不同形状的Perimeter方法实现各不相同,这样就可以执行与形状相对应的周长算法。

如果此时要增加三角形,要怎么改呢?答案是不用改,只需新增代码即可:

// 三角形类
class Triangle : Shape
{
	private double sideA;
	private double sideB;
	private double sideC;

	public double GetSideA() { return this.sideA; }
	public double GetSideB() { return this.sideB; }
	public double GetSideC() { return this.sideC; }

	public void SetSides(double a, double b, double c)
	{
		if (a < 0 || b < 0 || c < 0)
		{
			throw new Exception('...');
		}

		// 两边之和必须大于第三边
		if (a + b <= c || a + c <= b || b + c <= a)
		{
			throw new Exception('...');
		}

		// 两边之差的绝对值必须小于第三边
		if (Math.Abs(a - b) >= c || Math.Abs(a - c) >= b || Math.Abs(b - c) >= a)
		{
			throw new Exception('...');
		}

		this.sideA = a;
		this.sideB = b;
		this.sideC = c;
	}

	public override double Perimeter()
	{
		return this.sideA + this.sideB + this.sideC;
	}
}

var triangle = new Triangle();
triangle.setSides(3, 4, 5);
ComputePerimeter(triangle);

小结

开放-封闭原则,即对扩展开放对修改封闭,是面向对象的重要原则。其核心思想是对抽象编程,而不对具体编程。通过继承和多态机制,可以实现对抽象体(如Shape类)的继承,再通过覆写其方法(如不同形状的Perimeter方法)来改变行为的实现细节。

 

本篇到此结束,读者应着重理解面向对象的思想。下一篇将会讲述如何在JavaScript中模拟面向对象机制。

评论 (1条)

发表评论

(必填)

(选填,不公开)

(选填,不公开)

(必填)