0%

115. Java 面向对象

面向对象

  1. 例子
    1
    2
    3
    4
    class Person {
    public String name;
    public int age;
    }

    一个class可以包含多个字段(field),字段用来描述一个类的特征。上面的Person类,我们定义了两个字段,一个是String类型的字段,命名为name,一个是int类型的字段,命名为age。因此,通过class,把一组数据汇集到一个对象上,实现了数据封装。

    字段就是C++中的成员属性

  2. 创建实例: Person ming = new Person();
  3. 小结:
    • 在OOP中,class和instance是“模版”和“实例”的关系;
    • 类就是对象的模板(蓝图)
    • 定义class就是定义了一种数据类型,对应的instance是这种数据类型的实例;
    • class定义的field,在每个instance都会拥有各自的field,且互不干扰;
    • 通过new操作符创建新的instance,然后用变量指向它,即可通过变量来引用这个instance;
  • 访问实例字段的方法是变量名.字段名;
  1. 类和对象的关系

    • 类是一个抽象的概念,仅仅是模板,比如说:演员、总统
    • 对象是一个你能够看得到、摸得着的具体实体
    • 类的定义者和类的使用者是不一样的。越往下越具体,越往上越抽象。
  2. 基本步骤

    1. 发现类
    2. 找出属性(名词)
    3. 找出行为(动词)
    4. 数据抽象:是数据和处理方法的结合
  3. 定义类

    1. 代码
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      public class Actor {
      public String name;
      public char sex;
      public String job;
      public int age;

      public void eat(){
      ……
      }
      }
  4. 类图

    1. 直观、容易理解
    2. 参考工具:
      • StarUML
      • Astah
      • +号代表public,-号代表private
      • 属性名在前,后面跟冒号和类型名
      • 方法名在前,后面跟冒号和返回值类型
      • 如果有参数,参数的类型写法同上
      • Astah
  5. 实例化

    1
    2
    3
    Role role1 = new Role(); // 在堆空间里面分配了一个空间,把空间的地址赋给了role1,用哈希码来表示在虚拟机里面的内存地址
    Role role2; // 声明了一个Role类型的变量,叫role2(可以把Role看成自己定义的数据类型),但是还没有空间
    role2 = new Role(); // role2 一定要初始化,对象在运行时,一定要分配空间
  6. 方法

    把field从public改成private,外部代码不能访问这些field,以我们需要使用方法(method)来让外部代码可以间接修改field:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    public class Main {
    public static void main(String[] args) {
    Person ming = new Person();
    ming.setName("Xiao Ming"); // 设置name
    ming.setAge(12); // 设置age
    System.out.println(ming.getName() + ", " + ming.getAge());
    }
    }

    class Person {
    private String name;
    private int age;

    public String getName() {
    return this.name;
    }

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

    public int getAge() {
    return this.age;
    }

    public void setAge(int age) {
    if (age < 0 || age > 100) {
    throw new IllegalArgumentException("invalid age value");
    }
    this.age = age;
    }
    }

    虽然外部代码不能直接修改private字段,但是,外部代码可以调用方法setName()和setAge()来间接修改private字段。在方法内部,我们就有机会检查参数对不对。比如,setAge()就会检查传入的参数,参数超出了范围,直接报错。这样,外部代码就没有任何机会把age设置成不合理的值。

    同样,外部代码不能直接读取private字段,但可以通过getName()和getAge()间接获取private字段的值。

    所以,一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。

    调用方法的语法是实例变量.方法名(参数);。一个方法调用就是一个语句,所以不要忘了在末尾加;。例如:ming.setName(“Xiao Ming”);。

    1. 定义方法

      1
      2
      3
      4
      修饰符 方法返回类型 方法名(方法参数列表) {
      若干方法语句;
      return 方法返回值;
      }
    2. private方法

      有public方法,自然就有private方法。和private字段一样,private方法不允许外部调用,定义private方法的理由是内部方法是可以调用private方法的。

  7. this变量

    在方法内部,可以使用一个隐含的变量this,它始终指向当前实例。因此,通过this.field就可以访问当前实例的字段。如果没有命名冲突,可以省略this,但是如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上this

  8. 方法参数

    方法可以包含0个或任意个参数。方法参数用于接收传递给方法的变量值。调用方法时,必须严格按照参数的定义一一传递。(同C++)

  9. 可变参数

    可变参数用 类型... 定义,可变参数相当于数组类型:

    1
    2
    3
    4
    5
    6
    7
    class Group {
    private String[] names;

    public void setNames(String... names) {
    this.names = names;
    }
    }

    上面的setNames()就定义了一个可变参数。调用时,可以这么写:

    1
    2
    3
    4
    5
    Group g = new Group();
    g.setNames("Xiao Ming", "Xiao Hong", "Xiao Jun"); // 传入3个String
    g.setNames("Xiao Ming", "Xiao Hong"); // 传入2个String
    g.setNames("Xiao Ming"); // 传入1个String
    g.setNames(); // 传入0个String

    完全可以把可变参数改写为String[]类型:

    1
    2
    3
    4
    5
    6
    7
    class Group {
    private String[] names;

    public void setNames(String[] names) {
    this.names = names;
    }
    }

    但是,调用方需要自己先构造String[],比较麻烦。例如:

    1
    2
    Group g = new Group();
    g.setNames(new String[] {"Xiao Ming", "Xiao Hong", "Xiao Jun"}); // 传入1个String[]

    另一个问题是,调用方可以传入null:

    1
    2
    Group g = new Group();
    g.setNames(null);

    而可变参数可以保证无法传入null,因为传入0个参数时,接收到的实际值是一个空数组而不是null。

  10. 参数绑定: 传参的问题

    基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。

    引用类型的传递:将地址进行复制,如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class Main {
    public static void main(String[] args) {
    Person p = new Person();
    String[] fullname = new String[] { "Homer", "Simpson" };
    p.setName(fullname); // 传入fullname数组
    System.out.println(p.getName()); // "Homer Simpson"
    fullname[0] = "Bart"; // fullname数组的第一个元素修改为"Bart"
    System.out.println(p.getName()); // "Bart Simpson"
    }
    }

    class Person {
    private String[] name;

    public String getName() {
    return this.name[0] + " " + this.name[1];
    }

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

    Note: 因为传入的是字符串数组,因此将 fullname 数组的地址复制,传给了p.name,因此 p.namefullname 指向的是同一个字符串数组,所以这两个同时变化,但是变化的时候还是遵循引用的原则:Homer仍然存在,只是无法通过fullname[0] 进行访问罢了。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class Main {
    public static void main(String[] args) {
    Person p = new Person();
    String bob = "Bob";
    p.setName(bob); // 传入bob变量
    System.out.println(p.getName()); // "Bob"
    bob = "Alice"; // bob改名为Alice
    System.out.println(p.getName()); // "Bob"
    }
    }

    class Person {
    private String name;

    public String getName() {
    return this.name;
    }

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

    Note: 和上一个传入引用参数一样,复制的是地址,传入之后 bobp.name 指向了同一块内存,只不过 bob 改变之后会重新指向新的内存,所以这两个变量指向的内存就不一样了。

    注意:字符串和数组都是引用类型。

  11. 构造方法

    1. 由于构造方法是如此特殊,所以构造方法的名称就是类名。构造方法的参数没有限制,在方法内部,也可以编写任意语句。但是,和普通方法相比,构造方法没有返回值(也没有void),调用构造方法,必须用new操作符。(具体意义和C++一样)
    2. 没有在构造方法中初始化字段时,引用类型的字段默认是null,数值类型的字段用默认值,int类型默认值是0,布尔类型默认值是false:
    3. 可以对字段直接进行初始化:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Person {
    private String name = "Unamed";
    private int age = 10;

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

    构造方法的代码由于后运行,最终由构造方法的代码确定,即便已经直接将字段初始化了。

  12. 方法重载

    同C++

  13. 继承

    Student类包含了Person类已有的字段和方法,只是多出了一个score字段和相应的getScore()、setScore()方法。

    继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。当我们让Student从Person继承时,Student就获得了Person的所有功能,我们只需要为Student编写新增的功能。

    1. 实现

      Java使用extends关键字来实现继承:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      class Person {
      private String name;
      private int age;

      public String getName() {...}
      public void setName(String name) {...}
      public int getAge() {...}
      public void setAge(int age) {...}
      }

      class Student extends Person {
      // 不要重复name和age字段/方法,
      // 只需要定义新增score字段/方法:
      private int score;

      public int getScore() { … }
      public void setScore(int score) { … }
      }

      通过继承,Student只需要编写额外的功能,不再需要重复代码。

      Note: 子类自动获得了父类的所有字段,严禁定义与父类重名的字段!

      在OOP的术语中,我们把Person称为超类(super class)父类(parent class)基类(base class),把Student称为子类(subclass)扩展类(extended class)

    2. 继承树

      在Java中,没有明确写extends的类,编译器会自动加上extends Object。所以,任何类,除了Object,都会继承自某个类。

      Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有Object特殊,它没有父类。

      例如:

    3. protected

      继承有个特点,就是子类无法访问父类的private字段或者private方法。

      为了让子类可以访问父类的字段,我们需要把private改为protected。用protected修饰的字段可以被子类访问: 因此,protected关键字可以把字段和方法的访问权限控制在继承树内部,一个protected字段和方法可以被其子类,以及子类的子类所访问。

    4. super

      super关键字表示父类(超类)。子类引用父类的字段时,可以用super.fieldName。例如:

      1
      2
      3
      4
      5
      class Student extends Person {
      public String hello() {
      return "Hello, " + super.name;
      }
      }

      实际上,这里使用super.name,或者this.name,或者name,效果都是一样的。编译器会自动定位到父类的name字段。

      但是,在某些时候,就必须使用super。我们来看一个例子:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      public class Main {
      public static void main(String[] args) {
      Student s = new Student("Xiao Ming", 12, 89);
      }
      }

      class Person {
      protected String name;
      protected int age;

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

      class Student extends Person {
      protected int score;

      public Student(String name, int age, int score) {
      this.score = score;
      }
      }

      运行上面的代码,会得到一个 编译错误,大意是在Student的构造方法中,无法调用Person的构造方法。

      这是因为在Java中,任何class的构造方法,**第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句super();**,所以,Student类的构造方法实际上是这样:

      1
      2
      3
      4
      5
      6
      7
      8
      class Student extends Person {
      protected int score;

      public Student(String name, int age, int score) {
      super(); // 自动调用父类的构造方法
      this.score = score;
      }
      }

      但是,Person类并没有无参数的构造方法,因此,编译失败。

      解决方法是调用Person类存在的某个构造方法。例如:

      1
      2
      3
      4
      5
      6
      7
      8
      class Student extends Person {
      protected int score;

      public Student(String name, int age, int score) {
      super(name, age); // 调用父类的构造方法Person(String, int)
      this.score = score;
      }
      }

      如果父类没有默认的构造方法,子类就必须显式调用super()并给出参数以便让编译器定位到父类的一个合适的构造方法。

      这里还顺带引出了另一个问题:即子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。

    5. 阻止继承

      只要某个class没有final修饰符,那么任何类都可以从该class继承。

      从Java 15开始,允许使用sealed修饰class,并通过permits明确写出能够从该class继承的子类名称。

      1
      2
      3
      public sealed class Shape permits Rect, Circle, Triangle {
      ...
      }

      上述Shape类就是一个sealed类,它只允许指定的3个类(Rect, Circle, Triangle)继承它,否则就会报错。

      这种sealed类主要用于一些框架,防止继承被滥用。sealed类在Java 15中目前是预览状态,要启用它,必须使用参数–enable-preview和–source 15。

    6. 向上转型:一个子类类型安全地变为父类类型的赋值

      向上转型实际上是把一个子类型安全地变为更加抽象的父类型

      1
      2
      3
      4
      Student s = new Student();
      Person p = s; // upcasting, ok
      Object o1 = p; // upcasting, ok
      Object o2 = s; // upcasting, ok

      p也只能使用 Person类中有的字段和方法,不能使用 Student 添加的字段和方法

    7. 向下转型:把一个父类类型强制转型为子类类型

      不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来。

      1. instanceof实际上判断一个变量所指向的实例是否是指定类型,或者这个类型的子类。如果一个引用变量为null,那么对任何instanceof的判断都为false
      1
      2
      3
      4
      5
      Person p = new Student();
      if (p instanceof Student) {
      // 只有判断成功才会向下转型:
      Student s = (Student) p; // 一定会成功
      }

      从Java 14开始,判断instanceof后,可以直接转型为指定变量,避免再次强制转型。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      public class Main {
      public static void main(String[] args) {
      Object obj = "hello";
      if (obj instanceof String s) {
      // 可以直接使用变量s:
      System.out.println(s.toUpperCase());
      }
      }
      }

      等价于

      1
      2
      3
      4
      5
      Object obj = "hello";
      if (obj instanceof String) {
      String s = (String) obj;
      System.out.println(s.toUpperCase());
      }
    8. 组合和继承

      1
      2
      3
      4
      5
      class Book {
      protected String name;
      public String getName() {...}
      public void setName(String name) {...}
      }

      这个Book类也有name字段,那么,我们能不能让Student继承自Book呢?不可以

      从逻辑上讲,这是不合理的,Student不应该从Book继承,而应该从Person继承。

      究其原因,是因为Student是Person的一种,它们是is关系,而Student并不是Book。实际上Student和Book的关系是has关系。
      具有has关系不应该使用继承,而是使用组合,即Student可以持有一个Book实例:

      1
      2
      3
      4
      class Student extends Person {
      protected Book book;
      protected int score;
      }

      继承是is关系,组合是has关系。

  14. 多态

    在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。