java面向对象编程基础


类与对象

南邮校科协2021授课用

1、面向对象编程和面向过程编程

我们今天学习的java语言,是一门面向对象的语言,面向对象程序设计OOP是当今主流的程序设计范型,很多我们熟悉的语言比如python,golang等等都遵循这样的范型。

与之相对的是你们已经比较熟悉的c语言,这是一种面向过程的语言

对于面向过程的编程,Pascal语言的设计者有一个精确地概括:算法+数据结构=程序,即优先考虑如何操作数据(算法),接着考虑如何组织数据的结构(数据结构)以便操作数据。而面向对象的编程却将数据放在了第一位,然后才考虑算法。

这种特性有以下优点

  1. 功能的实现更加直观,便于理解,因而便于学习
  2. 代码的耦合性降低,利于代码的复用,且更容易找出bug

对于庞大的项目,比如一个浏览器,面向过程要写2000个过程,面向对象要写100个对象,每个对象20个方法,易于查找bug

2、类和对象简介

我这样概括类和对象的关系:类定义了一众对象的特征,一个对象就是这些特征具体的展现

我们先来看一个类和对象的例子

public class Dog {
    int size;
    String color;
    int age;
 
    void eat() {
    }
 
    void run() {
        sout("the dog is running");
    }
 
    void sleep(){
    }
}

可以发现,类中定义了一些变量和一些方法

一个类可以包含以下类型变量:

  • 局部变量:在方法、构造方法或者语句块中定义的变量被称为局部变量。变量声明和初始化都是在方法中,方法结束后,变量就会自动销毁。
  • 成员变量:成员变量是定义在类中,方法体之外的变量。这种变量在创建对象的时候实例化。成员变量可以被类中方法、构造方法和特定类的语句块访问。
  • 类变量:类变量也声明在类中,方法体之外,但必须声明为 static 类型。
public class Dog{
    String name; //成员变量
    static int age; //类变量
    
    void sleep(){
        int hours; //局部变量
    }
}

类变量必须用static声明,其效果类似c中的static类型变量,其与成员变量最大的区别在于:成员变量随对象的创建与回收而存在、释放,而类变量随类的加载与消失存在,消失,不常使用,可以不用深究。

对象

我们再来看一个对象的例子

public static void main(String[] args){
    Dog dog = new Dog();
}

使用类名 对象名 = new 构造方法名()的形式创建一个类的对象,这样的一个对象就可以访问类中的变量与方法了

public static void main(String[] args){
    Dog dog = new Dog();
    dog.age = 1;
    sout(dog.age); //1
    dog.run; //the dog is running!
}

构造函数

我们发现,创建对象时最后有一对括号,根据经验,括号代表了调用函数,那么这里调用了什么函数呢?

其实这里调用了上文说过的类的构造函数(方法),每个类都有构造方法。如果没有显式地为类定义构造方法,Java 编译器将会为该类提供一个默认构造方法。比如我们的Dog类虽然没有写构造方法,但其实隐藏了一个默认的无参构造方法,我们创建对象时调用的就是这个构造方法。

public class Dog {
    int size;
    String color;
    int age;
    String name;
    
    public Dog(){
        
    }        // hidden!
 
    void eat() {
    }
 
    void run() {
        sout("the dog is running");
    }
 
    void sleep(){
    }
}

在创建一个对象的时候,至少要调用一个构造方法。构造方法的名称必须与类同名,一个类可以有多个构造方法。

public class Dog {
    int size;
    String color;
    int age;
    String name;
    public Dog(){
        System.out.println("this is the default cons func");
    }        // hidden!

    public Dog(String name){
        this.name = name;
        System.out.println("with 1 param");
    }

    public Dog(String name,int age){
        this.name = name;
        this.age = age;
        System.out.println("with 2 param");
    }

    public static void main(String[] args){
        Dog dog = new Dog();
        Dog dog2 = new Dog("pipco");
        Dog dog3 = new Dog("you know who :D",114);

        System.out.println(dog2.name); //pipco
        System.out.println(dog3.age); //114
        
        dog.age = 1;
        System.out.println(dog.age); //1
    }
}

this

注意下这里的this,这是一个指向对象本身的指针(是的,java里当然有指针,为什么不呢)

public Dog(String name){
    this.name = name;
}

name变量是函数传入的形参,而这里的this.name因为指向了对象本身,所以指的是对象自己的变量

this还可以用来调用其他构造方法

public Dog(String color,int age){
    this.color = color;
    this.age = age;
}
public Dog(String color,int age,String name){
    this(color,age);    //这样就调用了上面的构造方法
    this.name = name;
}

需要注意,只能在构造函数中这样使用,且这样的this语句应该为构造函数中的第一条语句,并且只能调用一次

包与导入

最后,我们要学习源文件的声明规则

  • 一个源文件中只能有一个 public 类
  • 一个源文件可以有多个非 public 类
  • 源文件的名称应该和 public 类的类名保持一致。例如:源文件中 public 类的类名是 Employee,那么源文件应该命名为Employee.java。
  • 如果一个类定义在某个包中,那么 package 语句应该在源文件的首行。
  • 如果源文件包含 import 语句,那么应该放在 package 语句和类定义之间。如果没有 package 语句,那么 import 语句应该在源文件中最前面。

package语句可以将我们当前的代码打包,import语句可以从指定位置引入别的包,这样的设计方便我们复用已有的代码或引用之前的代码。

3、继承

继承是类之间的关系,我们将继承双方称为父类子类

继承就是子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为。

继承使用extends关键字

public class Animals{
       ......
}
——————————————————————————
public class Dogs extends Animals{
    ......
}

Dogs类继承了Animals类

为什么需要继承

从上面的例子可以看出,子类与父类有一定的包含关系,父类中的所有特性在子类中都有体现,而子类实现了父类没有的特殊特性。不难想到,如果不使用继承,分别设置两个类,就会出现大量重复无用的代码。

java继承类型

java中的继承不支持多继承,但支持多重继承

举例

public class major{
        private int id;
        private String name;
        public void majorTest(int id,String name) {
            this.id=id;
            this.name=name;
            System.out.println(id+name);
        }
        public major() {
            System.out.println("这是父类的构造方法");
        }
    }
class minor extends major{
        private int id;
        private String name;
        public void minorTest(int id,String name) {
            this.id=id;
            this.name=name;
            System.out.println(id+name);
            majorTest(id, name);
        }
        public minor(int id) {
            //super();
            this.id=id;
            System.out.println("这是子类的构造方法"+id);
        }
    }
——————————————————————————————————————————————————————————————
public class Extendtest {
    public static void main(String[] args) {
        major m1 = new major();
        minor m2 = new minor(1);
        
        m1.majorTest(1, "这是设定的major的值");
        m2.minorTest(2, "这是minor设定的值");
        m2.majorTest(3, "测试继承");
    }
}

输出结果如下

这是父类的构造方法
这是父类的构造方法
这是子类的构造方法1
1这是设定的major的值
2这是minor设定的值
2这是minor设定的值
3测试继承

根据这个例子,我们发现以下特性:

  1. 子类对象实例化时会自动调用同参数的父类的构造方法,结束后再调用子类的构造方法。如果没有则调用父类默认的构造器,如果要使用不同参的父类构造器,需要使用super(参数值)
  2. 子类实例化的对象可以直接调用父类中的方法

再看个菜鸟教程的例子加深下理解:

public class SuperClass {
    private int n;
    SuperClass(){
        System.out.println("SuperClass的默认无参构造");
    }
    SuperClass(int n) {
        System.out.println("SuperClass的一参构造");
        this.n = n;
    }
}

class SubClass extends SuperClass{
    private int n;

    public SubClass(){ // 自动调用父类的无参数构造器
        //super();
        System.out.println("SubClass的默认无参构造");
    }

    public SubClass(int n){
        super(300);  // 显式调用父类中带有参数的构造器
        System.out.println("SubClass的一参构造,参数为"+n);
        this.n = n;
    }
}
.......
public class Extends {
    public static void main(String[] args) {
        SubClass sc1 = new SubClass();
        SubClass sc2 = new SubClass(100);
    }
}

输出是

SuperClass的默认无参构造
SubClass的默认无参构造
SuperClass的一参构造
SubClass的一参构造,参数为100

super

super是一个类似于上文的this的关键字,可以理解为一个指向父类的指针。

通常我们会避免父类与子类有同名变量或函数,不过如果必须这样写,当我们要指定父类的变量或函数时可以使用super关键字,用法类似this。

super关键字更常见于构造方法中,子类的构造函数会默认在第一句加上super(); ,联系上文this的用法不难想到,这调用了父类的默认无参构造方法,因此super语句必须是子类构造方法的第一句。

Java重写(Override)与重载(Overload)

只做简单介绍

重写发生在继承的类间,子类可以定义和父类方法名、参数和返回值都一样的函数,其内容不同

重载是一个类中定义了多个方法名相同,而他们的参数的数量不同或数量相同而类型和次序不同,多个不同参的构造函数就是重载的体现

4、多态

多态是java面向对象中一个重要的特性,在此只做简单介绍

多态,就是同一个行为具有多个不同的表现形式或形态的能力。比如同样是单击鼠标左键的行为,在不同的软件中会产生不同的效果,这就是一种多态。

java编程中的多态有三个必要条件

  • 继承
  • 重写
  • 父类引用指向子类对象:Parent p = new Child();

当使用多态方式调用方法时,首先检查父类中是否有该方法,如果没有,则编译错误;如果有,再去调用子类的同名方法。

向上转型与向下转型

对象的多态有向上转型与向下转型

向上转型的使用场景主要是

1、定义了一个大类,里面写了很多方法,是普遍适用于这一大类的

2、定义了一些小类,他们继承了大类,这些小类要使用大类中的部分或者全部方法

3、但是这些小类继承的大类中的某些方法需要与大类的普遍方法区分开来

比如说,大类游戏中有小类csgo 雀魂osu ,同时,大类游戏中定义了三个方法

public class Game{
    public void start(){
    System.out.println("开冲!");
    }
    public void play(){
    System.out.println("芜湖!");
    }
    public void end(){
    System.out.println("wdnmd!");
    }
}

显然,所有的小类游戏都适用这三个方法,可是不同游戏的玩法不同,那么我们就要在小类游戏里重写这个play方法

public class OSU extends Game{
    public void play(){
    System.out.println("哪亮点哪");
    }
}

而我们创建对象时就可以这样创建

Game g1 = new OSU();
Game g2 = new CSGO();
Game g3 = new MajSoul();    //是不是符合前述多态的必要条件

这就是向上转型,这样可以方便后续对象创建和管理

而向下转型,就是在上面这样的情况下出现了特例,比如 csgo 类中不仅有这三种方法,还有另一种方法是 buy(); 但很不幸,我们创建对象时使用的类Game中并没有这个方法,因此会报错,这时我们就要向下转型

CSGO gogo = (CSGO)g2;

这样,我们就可以执行 gogo.buy(); 了,这里要注意,java要求向下转型必须强制转换类型

当然,也可以这样转换类型来调用

((CSGO)g2).buy();

5、抽象类、方法与接口

回顾一下我们对类与对象的定义:类定义了一众对象的特征,一个对象就是这些特征具体的展现

再回顾一下多态的定义,我们会发现,这个对类的定义不尽全面。事实上,我们总会遇到要创建一个不需要对象的类的需求,这时java中的抽象abstract概念就很重要了

java中的抽象可以概括为一句话:只定义,不实现

抽象类

抽象类,使用abstract关键字修饰类来定义

public abstract class Animals {
    int age;
    String species;

    public abstract void eat();
    /*public abstract void run(){
        //abstract func!
    }*/
    public void sleep(){
        System.out.println("sleeping...");
    }
    Animals(){
        System.out.println("default abstract cons");
    }
}

可以正常地定义属性,方法和构造方法

但如果我们试图创建一个对象实例化这个抽象类

public class AbstractDemo {
    public static void main(String[] args) {
        Animals animals = new Animals();
                          ^^^ err!
    }
}

编译器就会报错,这是因为抽象类无法被实例化,这和它的定义一致。

抽象方法

看一下刚才我们写的抽象类中这样一个方法

    public abstract void eat();
    /*public abstract void run(){
        //abstract func!
    }*/

我们发现它也被abstract修饰了,这是一个抽象方法,它有这样的特性

  1. 抽象方法必须存在于抽象类中,但抽象类中可以没有抽象方法
  2. 抽象类的子类必须重写它的抽象方法
  3. 抽象类没有函数体,因为它只定义,不实现

此时我们写一个类继承我们的抽象类,idea会自动提示我们重写抽象方法

public class Cat extends Animals {
    @Override
    public void eat() {
        System.out.println("cats eat");
    }
}

这样我们就可以利用多态,向上转型创建一个对象

public class AbstractDemo {
    public static void main(String[] args) {
        //Animals animals = new Animals();
        Animals cat = new Cat();
        cat.age = 1;
        cat.eat();
    }
}

接口

接口interface 是一个抽象类型,代表了抽象方法的集合,它的编写和类很相似,但两者是完全不同的概念:类描述对象的属性和方法,接口则包含类要实现的方法。接口使用interface定义,有以下特性

  1. 接口中所有的方法都是抽象方法,不可有正常方法,所有的变量都是public static final的,所有的方法都是public abstract
  2. 不可被实例化成对象,但可以被类实现implement
  3. 实现接口的类必须重写接口中所有的方法,除非它是个抽象类
  4. 支持多继承,也支持多实现
//GameInterface
public interface GameInterface {
    void start();
    /*void play(){
        //err
    }*/
    void end();
}
//Fps
public interface Fps extends GameInterface {
    String type = "fps"; //public static final
    void play();
}
//Csgo
public interface Csgo extends Fps,GameInterface {
    String name = "csgo";
    void target();
}
//PC
public interface PC {
    void breakdown();
}
//PlayCsgo
public class PlayCsgo implements Csgo,PC {
    @Override
    public void target() {
        System.out.println("defuse/plant the bomb");
    }

    @Override
    public void play() {
        System.out.println("rush");
    }

    @Override
    public void start() {
        System.out.println("hfgl");
    }

    @Override
    public void end() {
        System.out.println("gg");
    }

    @Override
    public void breakdown() {
        System.out.println("damn");
    }
}

6、java修饰符

修饰符用来定义类、方法或者变量,通常放在语句的最前端,我们已经见过不少了,比如

public class Dog{
    ......
}

Java 的类有 2 种访问权限: publicdefault

而方法和变量有 4 种:publicdefaultprotectedprivate

  • private  在当前类中可访问
  • default 在当前包内可访问
  • protected 在当前类和它派生的类中可访问
  • public 公众的访问权限,谁都能访问

修饰符:abstract、static、final

  • abstract: 表示是抽象类。 使用对象:类、接口、方法
  • static: 可以当做普通类使用,而不用先实例化一个外部类。(用他修饰后,就成了静态内部类了)。 使用对象:类、变量、方法、初始化函数(注意:修饰类时只能修饰 内部类 )
  • final: 表示类不可以被继承。 使用对象:类、变量、方法

7、封装

封装可以被认为是一个保护屏障,防止该类的代码和数据被外部类定义的代码随机访问。

public class Dogs{
    private int age; //合理的设计中,仅本类使用的变量应该设计为private类型
    private String name;
    
    public int getAge(){ //提供getter与setter以修改变量
        return this.age;
    }
    public void setAge(int age){
        this.age = age;
    }
    
    public String getName(){
        return name;
    }
    public void setName(String name){
        this.name = name;
    }
}

那么封装有什么优点呢?

首先封装提高了安全性。如果将所有变量设计成public,那么可以任意更改变量,封装后则必须调用setter和getter,因为必须要通过方法,所以我们可以在方法中加入一些安全检查,比如赋值合法性判断等等

public class Dog{
    private int age;
    
    public void setAge(int age){
        if(age>=0 && age<=30){
            this.age = age;
        }else{
            throw new Exception("wrong age!");
        }
    }
}

其次提高了代码复用性。如果仅仅是安全判断,那么就算不封装,也可以在实例化对象后对对象的值判断

Dog d = new Dog();
d.age = 10;
if(d.age<0 || age>30){
    throw new Exception("wrong age!");
}    //correct?

但如果有多个对象,就要多次判断,或者再写一个判断的函数,这完全是多此一举。并且,如果判断的条件发生变化,我们需要修改多处代码,这很不合理。

8、简单内存分析

最后我们简单的看一下对象在内存中的表现,相信这可以让你对面向对象理解更深。

首先我们将程序运行时可用的内存空间分为堆heap栈stack两个部分

栈中所有的数据已知且占用已知的大小,栈以放入值的顺序存储值并以相反顺序取出值。这也被称作 后进先出last in, first out

堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针pointer)你可以将指针存储在栈上,不过当需要实际数据时,必须访问指针

入栈比在堆上分配内存要快,因为(入栈时)操作系统无需为存储新数据去搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为操作系统必须首先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。

访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问

jvm中的内存区主要有以下五个部分

简单的说,我们在栈上储存基本数据类型变量局部变量

在堆上存储对象

举个例子,我们这样创建对象

类名 对象名 = new 构造方法名([参数1,参数2...]);

其实这是两个部分,就像int a = 1; 一样,等号的作用是赋值。

前面的部分创建了一个 [类名]格式的名叫[对象名]引用类型变量,这部分存放在栈当中。后面的部分调用类中构造方法创建了一个对象,这部分存放在堆当中,这部分在堆中分成两个部分储存,一个是其在堆上的地址,另一个是对象中的参数等等

引用类型变量在栈中储存的是一个地址,有点像c中的指针,地址指向堆中的某个位置,那里存放着对应对象的值

栈中储存的值是临时的,在调用结束后就会被释放,堆中的值则不会,除非被认定为无用数据,才会被gc(垃圾回收机制)回收

可以由这个例子更好的理解

public class Game{
    String name;
    public Game(String name){
        this.name = name;
    }
}
===================================
public class Main{
    public void test(Game g){
        g = new Game("moba");
    }
    public void static main(String[] args){
        Game g = new Game("fps");
        test(g);
        System.out.println(g.name)
    }
}

执行结果是

moba

执行过程如下

这是因为,我们传入test方法的参数是g,是栈上存在的一个值,我们在test()中新建的对象是在堆上的内容为name=moba的对象,我们给g这个栈上的值重新赋值,就是把内容为moba的对象的地址赋给了它,这样它指向的就是这个新的对象,而不是原来名为fps的对象了

如果想要修改堆上原来的对象,而不是创建一个新的对象重新赋值给栈上的值,可以这样写

public void test(Game g){
    g.name = "moba"
}

也能起到同样的效果,但实现方法则是直接修改了堆上对应对象的值

声明:punkginger's blog|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA协议进行授权

转载:转载请注明原文链接 - java面向对象编程基础


曾有言“将两件不相干的事物的名称组合在一起就是一个摇滚乐队名”,我也许有这种潜质...?