侦探大挑战
128.12M · 2026-04-22
我们将类看做自定义数据类型,类由属性和操作(方法)两部分组成。其中属性分为类属性和实例属性,方法分为类方法和实例方法。因此数据类型由下面四个部分组成:
类变量和实例变量可以统称为成员变量,类方法和实例方法可以统称为成员方法。类变量可以称为静态变量,类方法还可以称为静态方法。
类方法就是在定义的时候加上static关键字修饰的方法,这样的方法别人可以直接使用类名.方法名()的形式调用。
类变量主要描述类型本身具备的属性,一般用来定义类型中的常量。例如Math类里面就定义了一个常量PI:
public static final double PI = 3.141592653589793;
public修饰符表示这个变量是公开的,所有人都可以访问。static修饰符表示这是一个静态变量,别人可以通过类名.变量名的方式访问。final修饰符表示这是一个常量,无法修改。实例变量表示具体实例具有的变量,实例方法表示具体实例可以进行的操作。
class Point {
public int x;
public int y;
public double distance() {
return Math.sqrt(x * x + y * y);
}
}
上面是一个简单的类的例子,定义了一个名叫Point的类。类里定义了两个实例变量x和y,还定义了一个实例方法distance,这个方法计算并返回该点到原点的距离。可以发现在实例方法里面可以直接使用实例变量。实际上:
下面我们简单使用一下定义的Point类:
Point p = new Point(); // 创建 Point 类的对象 p
p.x = 1; // 给实例变量 x 赋值
p.y = 2; // 给实例变量 y 赋值
System.out.println(p.distance()); // 调用实例方法
这个例子比较简单,但是有一点。对象的创建和数组定义类似,变量名p里面存储着对象空间的地址,默认为null,代码里面我们通过new Point()分配对象空间并将地址赋给变量p。注意,所有的成员变量没有初值都会给默认的初值,数值类型初值都是 0,boolean类型默认初值是 false,char类型默认初值也是 0。
Tip: 实例变量一般不设为public,即我们一般不允许直接访问这个实例变量。而是使用private修饰实例变量,然后提供对应的getter和setter。这样做的好处是能够有效避免异常值的出现。举个例子,实例变量的含义是考试成绩,如果赋值-1进去怎么办?
我们说所有成员变量都会有一个默认的初值,但如果我想改变这个初值怎么办?
对于实例变量,可以直接在定义的时候给初值或者使用代码块:
public int x = 1; // 定义变量的时候直接赋值
public int y;
{ // 使用代码块进行实例变量的初始化
y = 3;
}
对于实例变量的初始化,有以下注意点:
对于静态变量,可以直接定义的时候指定或使用静态代码块:
public static int v1 = 1; // 定义变量的时候直接赋值
public static int v2;
static { // 使用静态代码块初始化
v2 = 2;
}
对于静态变量的初始化,有以下注意点:
问:普通初始化代码块里面能不能对静态变量初始化?
答:可以。只不过这样的初始化每次对象的创建都会执行,里面可以使用实例方法或实例变量给静态变量初始化,因为此时实例方法和变量都已经存在。
上面介绍了初始化成员变量的方法,其实还可以通过构造方法来做这个事情。我们使用new创建一个对象的时候需要做:1. 申请内存。2. 做实例变量的初始化操作,包括执行代码块的内容。3. 调用构造方法。
构造方法必须与类名相同,且不能有返回值类型,见下面的例子:
// 无参构造
public Point() {
this(0, 0); // 调用下面的构造方法
}
// 有参构造,参数没有要求,按需设立
public Point(int x, int y) {
this.x = x; // 给成员变量 x 赋值
this.y = y; // 给成员变量 y 赋值
}
看以看出我们使用到了关键字this,它表示当前实例,有两个作用:
x,所以直接访问x实际上是在访问局部变量。使用this表明要给成员变量x赋值。有了构造方法之后,我们可以在创建对象的时候可以直接使用Point p = new Point(1, 2);。
Java 有默认的构造方法,这个默认的构造方法里面什么都不做,也没有参数。但只有我们没有手动定义构造方法的时候这个默认构造才会存在,一旦我们定义了构造,那么默认构造就不存在了。基于此,若是我们自己定义的构造都是带参数的,那么创建对象的时候new Point()就会报错了,因为我们有自定义构造,所以默认无参构造就没有了,此时在这么调用就不对了。
构造方法不一定非要public,还可以定义private的构造方法,一般使用私有构造的场景:
Math类。为解决类命名冲突的问题,我们引入包的概念。包相当于一个路径,各部分使用.分隔。带有包路径的类名称为完全限定名。定义类的时候应该先使用package关键字声明报名:
package com.luyan;
public class Demo {
}
包名要和文件目录结构匹配,假设源文件的根目录是<?>/src,那么类Demo的源文件路径应该是<?>/src/com/luyan/Demo.java。建议包名使用域名的反写,以避免包名冲突。
类之间引用有以下注意点:
java.lang包是例外(不需要引入也可以使用)。例如我想用Arrays类里面的sort方法,有以下两种方法:
int[] arr = {1, 2, 3};
java.util.Arrays.sort(arr);
import java.util.Arrays;
Arrays.sort(arr);
Java 使用import关键字来导包,导包的时候还可以使用*来导入包下的所有类:import java.util.*;表示导入java.util下面所有类。但是要注意这不会递归导包,也就是说util子包下面的类是无法导入的。Java 没办法同时导入两个同名的类,遇到这样的情况只能导入一个,另一个使用完全限定名。除此之外,Java 可以使用静态导入的方式导入类公开的静态方法和静态变量。
import static java.lang.System.out; // 导入静态变量 out
out.println("Hello"); // 直接使用 out
可见性分为public、private、默认(不写)、protected,它们可见范围:
public表示所有人可以访问。private表示只有类内部可以访问。默认表示包内可访问,包外不可访问(包内指的是同一级目录,子包也不可访问)。protected表示包内或子类可访问。根据可见性小到大排序有:private < 默认 < protected < public。
导出jar包的时候导出的是字节码文件,而不是源文件。假设字节码文件的目录是E:bincomluyanDemo.class,那么导包步骤是:
E:bin目录下。jar -cvf <文件名>.jar <最上层包名>,这个例子就是jar -cvf demo.jar com。实际上jar包就是一个压缩包,我们完全可以解压缩看到里面的内容。
自然界的类之间天然存在继承关系,例如动物类和狗类,狗属于动物,因此动物类是父类,狗类是子类。Java 里面也有继承的关系,父类又可以称为基类,子类又可以称为派生类。
之所以叫继承,是因为子类会继承父类的属性和行为,同时子类还可以拓展自己的属性和行为。使用继承有许多好处:
Java 里即使没有给类指明父类,其也会有一个隐含的父类Object,Object是所有类或直接或间接的父类。Object类没有定义属性,但是定义了一些方法:
我们主要看看toString()方法,这个方法目的是返回对象的文本描述。一般情况下,直接打印对象的时候会默认打印这个函数的输出。这个方法的默认实现是:
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
因此默认情况下,打印一个对象打印出来的是类似于com.luyan.Point@28a418fc这样的字符串,字符串前半部分是类的完全限定名,后半部分是对象的哈希值(一般是对象的十六进制地址)。
上面的toString()方法一般来说是不够用的,我们想要描述一个对象可能涉及到这个对象的一些属性。但是父类并不能提前知晓子类的属性,这个时候我们就可以在子类重写toString()方法以达到自定义的目的。下面的例子是Point类重写的toString()方法:
@Override
public String toString() {
return "(" + x + ',' + y + ")"; // 按照格式返回横纵坐标
}
上面的@Override表示这个方法是重写的父类的方法,不写也可以,但写一下真假可读性。
我们接下来使用图形类来展示继承体系,首先我们打算设计“圆、线段、箭头”这三个图形类。
这三个类都具有颜色这个属性,也都具有draw这个行为,那么我们就可以抽象一个父类Shape来。
class Shape {
private static final String DEFAULT_COLOR = "black";
private String color;
public Shape() {
this(DEFAULT_COLOR);
}
public Shape(String color) {
this.color = color;
}
public void draw() {
System.out.println("Draw Shape");
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
}
这个类比较简单,实例变量color表示图形的颜色。类里定义了静态常量DEFAULT_COLOR表示默认颜色。定义draw()方法实现图形的绘制,这边就是简单的一句输出。
接下来设计“圆”这个类,圆新增了圆心和半径两个属性,还新增了area()方法用来计算面积。
class Circle extends Shape {
private Point center;
private double r;
public Circle(Point center, double r) {
this.center = center;
this.r = r;
}
@Override
public void draw() {
System.out.printf("Draw Circle at %s with r %f, using color %s.",
center, r, getColor());
}
public double area() {
return Math.PI * r * r;
}
public Point getCenter() {
return center;
}
public void setCenter(Point center) {
this.center = center;
}
public double getR() {
return r;
}
public void setR(double r) {
this.r = r;
}
}
这个类重写了父类draw()方法,并自定义一个求面积的方法。对于这个例子有以下几个注意点:
extends关键字表示继承,这边Circle继承于Shape。getColor()方法获取图形的颜色。new的时候,在调用子类构造之前,一定会先调用父类的构造。如果没有明确写调用父类哪个构造,那么将会调用父类的无参构造,若这种情况下父类没有无参构造是会报错的。上面的例子,没有显式调用父类构造,所以默认调用了父类的无参构造,因此图形颜色是默认的black。接下来我们设计“线段”类,这个类新增了两个端点以及一个计算长度的方法。
class Line extends Shape {
private Point start;
private Point end;
public Line(Point start, Point end, String color) {
super(color);
this.start = start;
this.end = end;
}
public double length() {
return start.distance(end);
}
@Override
public void draw() {
System.out.printf("Draw Line from %s to %s, using color %s.",
start, end, getColor());
}
public Point getStart() {
return start;
}
public void setStart(Point start) {
this.start = start;
}
public Point getEnd() {
return end;
}
public void setEnd(Point end) {
this.end = end;
}
}
这个例子主要出现了一个关键字super,这个关键字和this有些像,它的作用:
super关键字指定调用父类的哪一个构造方法,必须放在构造的第一行。这个例子里面指定调用父类带参的构造。super可以访问父类非私有的成员变量,这个主要当父类和子类有同名变量的时候用作区分。super可以调用父类的非私有方法,这个用的比较多。因为子类在重写完父类某方法之后,调用的就是子类重写后的方法了,这时候想要调用原来的方法,可以使用super关键字。super和this看起来是很像的,一个表示父类,一个表示自己。但是两者有很大的不同:
this引用一个对象,是实实在在的,是可以作为参数和返回值存在的。super只是一个关键字,并不能作为参数之类的存在,它的作用只是告诉编译器将要访问父类的相关属性方法。接下来我们设计“箭头”类,箭头相比于线段多了两个表示是否存在两端箭头的属性。
class ArrowLine extends Line {
private boolean startArrow;
private boolean endArrow;
public ArrowLine(Point start, Point end, String color,
boolean startArrow, boolean endArrow) {
super(start, end, color);
this.startArrow = startArrow;
this.endArrow = endArrow;
}
@Override
public void draw() {
super.draw();
if (startArrow)
System.out.println("Draw start arrow.");
if (endArrow)
System.out.println("Draw end arrow.");
}
public boolean isStartArrow() {
return startArrow;
}
public void setStartArrow(boolean startArrow) {
this.startArrow = startArrow;
}
public boolean isEndArrow() {
return endArrow;
}
public void setEndArrow(boolean endArrow) {
this.endArrow = endArrow;
}
}
这个类实现有两个地方需要注意:
Line没有无参构造,所以在本类的构造方法里面需要显式调用父类的构造。draw()方法里面使用super关键字是因为想要调用父类的draw方法。使用继承就是方便管理多个不同的子类,下面我们设计一个图形管理器,用于添加图形以及对维护的图形进行绘制。
class ShapeManager {
private static final int MAX_NUM = 100;
private Shape[] shapes = new Shape[MAX_NUM];
private int shapeNum = 0;
public void addShape(Shape shape) {
if (shapeNum >= MAX_NUM) return;
shapes[shapeNum++] = shape;
}
public void draw() {
for (int i = 0; i < shapeNum; ++i) {
shapes[i].draw();
}
}
}
这个类主要维护一个Shape数组,具备添加图形和绘制所有图形的功能。
public static void main(String[] args) {
ShapeManager manager = new ShapeManager();
manager.addShape(new Circle(new Point(2, 3), 1));
manager.addShape(new Line(new Point(1, 1), new Point(2, 2), "green"));
manager.addShape(new ArrowLine(new Point(2, 3), new Point(4, 5),
"red", true, false));
manager.draw();
}
上面写了一段测试代码,创建了一个圆、一条直线和一个箭头。我们将这些图形添加到管理器里面并统一绘制出来。
Circle明明表示圆,但是我们把它看做一个图形是没有问题的。因此使用Shape类型变量来引用圆对象是合理的,这个叫做向上转型。Shape类型的对象可以引用任意一个子类的对象,这叫做多态,即一种类型的变量可以实际引用多种类型的对象。对于一个变量Shape shape;来说,我们称Shape是它的静态类型,Circle/Line/ArrowLine是它的动态类型。我们使用shape.draw()调用的是动态类型的方法,这叫做动态绑定。
第一个问题之前提过,创建子类对象的时候,在调用子类构造之前会先调用父类构造。如果没有手动指定调用父类哪个构造,那么就会默认调用父类无参构造,若此时恰巧父类没有无参构造,那就会报错了。
第二个问题是在父类的构造函数里面调用可被重写的成员方法时可能遇到的问题:
class Base {
public Base() {
test();
}
public void test() {
}
}
class Child extends Base {
int a = 123;
@Override
public void test() {
System.out.println(a);
}
}
Child c = new Child();
c.test();
上面的代码运行会输出什么?
答:0和123。
下面主要谈谈为什么第一次输出0。
test函数。test函数,输出变量a的值。a的值为0,故而输出0。从这个例子可以看出,父类构造中调用能被子类重写的方法是不太好的。因此我们平时编程的时候建议只在父类构造里面调用私有方法,因为私有方法不能被重写。
之前说过,子类能够重写父类非私有的实例方法,调用的时候会动态绑定,最终会执行子类的方法。那么对于实例变量、静态变量、静态方法而言,若是子类也有同名的变量方法,怎么执行?
首先,重名(子类和父类同时具有名字一样的属性或方法)是可以的。具体怎么执行分情况:
class Base {
String a = "base";
static String b = "static_base";
static void test() {
System.out.println("base_" + b);
}
}
class Child extends Base {
String a = "child";
static String b = "static_child";
static void test() {
System.out.println("child_" + b);
}
}
Child c = new Child();
Base b = c;
System.out.println(c.a);
System.out.println(c.b);
c.test();
System.out.println(b.a);
System.out.println(b.b);
b.test();
执行上面的代码输出:
child
static_child
child_static_child
base
static_base
base_static_base
根据变量的静态类型确定访问变量或方法的行为称为静态绑定,静态绑定在编译阶段即可决定,动态绑定要到程序运行是才能确定。实例变量、静态变量、静态方法和私有方法都是采用静态绑定。
上面说的是父类和子类两者都有的情况。下面我们总结访问变量或方法时各种情况下的访问结果(不考虑私有变量方法和成员方法,因为私有的只有类内部可以访问,成员方法是动态绑定):
重载是指方法的名称相同但参数的签名不同(参数个数、类型或顺序不同),重写是指子类重新定义和父类参数签名相同的方法。我们在调用函数的时候,实参和形参不要求完全一致,只要实参精度不高于形参精度就可以。例如函数形参是long类型,实参是int类型也能调用。
假设父类和子类都有名为sum的函数:
sum函数,只会在父类里面寻找参数类型匹配的函数调用,找不到报错。sum函数,依据参数类型情况:子类型对象完全可以赋值给父类型的变量,这叫向上转型。那父类型的变量能不能赋值给子类型的变量呢?我们可以使用强转来尝试转换,如果类型是匹配的就可以,否则不行。
Child c = new Child();
Base b = c;
c = (Child) b; // 成功,因为此时的 b 本质上就是 Child
Base b = new Base();
Child c = (Child) b; // 失败,因为此时 b 并不是 Child 类型的
如果确实需要向下转型,但是又怕出错。可以使用instanceof关键字判断能否转型,例如使用b instanceof Child可以返回变量b引用的对象是否是Child类或其子类的对象。
当我们使用子类重写父类的方法时,重写的方法不能降低可见性,可以提高。也就是说父类有个方法的可见性是protected,那么子类重写时可见性必须大于等于protected。
有时我们不希望某些非私有的方法被重写,我们在定义方法时候加上final关键字即可。类似的,我们可以在定义类的时候加上final关键字,这样这个类就是不可继承的。
所谓类加载是指将类相关的信息加载到内存之中的方法区。Java 的类是动态加载的,即第一次使用到这个类的时候才回去加载它,类加载只会进行一次。
首先我们要知道一个类包含哪些信息:
类加载的过程和顺序是这样的:
0,boolean 类型是false,引用类型是null)。从流程可以看出,加载父类的时候子类的所有信息都已经在内存之中了,类初始化代码也是父类先执行,然后是子类。
一个类的多个对象之间互不干涉对方的实例变量,也就是说每个对象的实例变量都是独立的。那么每次创建的对象内存里面都包含着属于自己的实例变量。静态变量和成员方法都是共享的。
对象的创建过程如下:
我们假设基类是Base,子类是Child。这两个类都有实例变量a,那么我们执行Child c = new Child(); Base b = c;之后的内存图是这样的:
不要搞混,变量是存在栈里面的,对象的内容是存在堆里面的,类相关信息是存在方法区的。
在Child c = new Child(); Base b = c;例子中,变量b和c的静态类型不一样。但实际上b和c指向同一个对象,若此时b调用action方法,肯定是从类Child里面寻找,找不到会到父类Base里面寻找,而这也是动态绑定的原因。
对于一些继承关系比较深的时候,层层寻找方法效率比较低下。所以很多实现都会在类加载的时候为每一个类创建一个虚方法表:
继承功能很强大,但是随之也有一个很严重的问题——破坏封装性。所谓封装就是隐藏实现细节,提供简化接口即使用者只需要关注怎么用而不需要关注怎么实现。下面使用一个例子简单阐述继承是如何破坏封装性的:
class Base {
private static final int MAX_NUM = 100;
private int[] arr = new int[MAX_NUM];
private int count = 0;
public void add(int n) {
arr[count++] = n;
}
public void addAll(int[] arr) {
for (int n : arr) {
this.arr[count++] = n;
}
}
}
class Child extends Base {
private int sum = 0;
@Override
public void add(int n) {
super.add(n);
sum += n;
}
@Override
public void addAll(int[] arr) {
super.addAll(arr);
for (int n : arr) {
sum += n;
}
}
public int getSum() {
return sum;
}
}
基类的功能就是添加元素,添加分为一个个添加和批量添加。子类的功能是想在添加的基础上能够对添加的元素求和。表面上,父类实现的功能只管添加,子类重写方法时添加求和的功能即可。但实际上,父类方法的实现细节关乎子类的功能是否正常。例如,我把父类的addAll方法重写为:
public void addAll(int[] arr) {
for (int n : arr) {
add(n);
}
}
这样改完之后,我们使用子类批量添加功能时,所有元素求和都会多求和一次。
此时,我们必须修改子类的实现,这就破坏了封装性。
正是因为继承的这个缺点,所以我们要尽可能的避免使用继承:
final使用final修饰类,这个类直接不可继承;final修饰方法,这个方法不可被重写。
我们看一下对于上面的例子改成组合之后是怎么实现的:
class Child {
private Base base;
private int sum = 0;
public Child() {
base = new Base();
}
public void add(int n) {
base.add(n);
sum += n;
}
public void addAll(int[] arr) {
base.addAll(arr);
for (int n : arr) {
sum += n;
}
}
public int getSum() {
return sum;
}
}
从这个例子可以看出,使用组合之后,Base 类的实现就不重要了,我只需要关注我自己的功能即可。但这就带来了另一个问题——Child的对象就不能当做Base类进行统一处理了。这个问题我们只需要使用接口就可以解决。