「BUAA-OO」"浅谈"OOPre


OOpre 作为作为具有北航计算机学院特色的 Pre 系列课程,以其不俗的码量和难以捉摸的 Junit 让每个 6 系学子爱之深而恨之切。我们在 OOPre 课程中的学习重点不在于 Java 复杂语法的掌握,而在于面向对象编程思想的初步建立,从而为我们之后的 OO 正课的学习打下基础。个人感觉对于 java 小白来说还是受益匪浅。

本文会先简单铺垫一点 java 基础语法,主要还是希望通过阐述面向对象编程常用的四大特性:封装继承多态和抽象,以及设计模式递归下降法等内容来帮助大家理解OOPre课程的核心内容。由于大家时间有限,本文不会大量堆砌 OOpre 课程代码,欲知2025 年秋季学期 OOPre 课程完整代码可以看笔者的个人GitHub仓库

同时本文也适合想要入门 Java 的同学,最后的 Java 项目编程规范可以帮助大家写出更优雅的代码。


Java基础语法简介

static
读取
数据容器
final
Character


面向对象编程课程内容简介

四大特性

封装

封装是面向对象编程的基本特性之一,相对易于理解,它将数据和操作数据的方法绑定在一起,隐藏了对象的内部实现细节。通过封装,我们可以保护对象的状态不被外部直接访问,从而提高代码的安全性和可维护性。在Java中,封装通常通过访问修饰符(如private、protected、public)来实现。一句话:封装就是把属性私有化,通过公共方法来访问和修改属性。

看代码:

class Person {
private String name = "JACK"; //name不可以在Person类外部访问
public String getName() { //可以通过public get方法在外部访问
return name;
}
}

继承

继承是面向对象编程的另一个重要特性,它允许我们创建一个新类(子类)来继承一个已有类(父类)的属性方法。同时子类也可以重写(Override)父类的方法或者定义子类特有的方法。通过继承,我们可以实现代码的重用和扩展,从而提高开发效率。在Java中,继承通过关键字extends来实现。一句话:继承就是子类自动拥有父类的属性和方法。

看代码:

class Animal {
String food ;
public Animal(String food) {
this.food = food;
}
public void eat() {
System.out.println("I like eat " + food);
}
}

class Dog extends Animal {
private String gender;
public Dog(String food,String gender) {
//调用父类的构造方法,必须放在第一行
super(food);
this.gender = gender;
}
//重写父类的方法
@Override
public void eat() {
System.out.println("Dog like eat " + food);
}
//定义自己方法
public void showGender() {
System.out.println("My gender is " + gender);
}
}

在Main函数中使用时,编译器会优先调用子类的方法,如果子类没有重写父类方法会调用父类方法,但是如果父类也没有这个方法就会喜提Cannot resolve method

多态

多态允许我们通过父类的引用来指向子类的对象,从而实现同一操作在不同对象上的不同表现形式。多态的实现方式主要有两种运行时多态编译时多态

运行时多态:通过方法重写实现,即子类重写父类的方法,在运行时根据对象的实际类型来决定调用哪个方法。

看代码:

class Person {
public void speak(){
System.out.println("Hello I am a person");
}
}

class Student extends Person {
@Override
public void speak(){
System.out.println("Hello I am a student");
}
}

public class Main {
public static void main(String[] args) {
Person p1 = new Person();
Person p2 = new Student(); //父类引用指向子类对象
p1.speak(); //输出: Hello I am a person
p2.speak(); //输出: Hello I am a student
}
}

编译时多态:通过方法重载实现,即在同一个类中定义多个同名但参数不同的方法,编译器根据传入参数的类型和数量来决定调用哪个方法。

看代码:

class MathUtils {
//方法重载
public int add(int a, int b) {
return a + b;
}

public double add(double a, double b) {
return a + b;
}
}
public class Main {
public static void main(String[] args) {
MathUtils mu = new MathUtils();
System.out.println(mu.add(2, 3)); //调用int版本,输出: 5
System.out.println(mu.add(2.5, 3.5)); //调用double版本,输出: 6.0
}
}

抽象

抽象即隐藏具体的实现细节,只向外暴露清晰的使用接口。实现抽象主要有两种方法:抽象类接口

抽象类
当我们发现一些类之间有共同的行为,但是这些行为在每个类中的具体实现方式不同,我们可以将这些共同的行为提取到一个抽象类中,并定义为抽象方法。

抽象类不能被实例化,只能被继承。
抽象类中可以包含抽象方法和具体方法。
抽象方法必须被子类重写。除非子类也是抽象类。

看代码:

abstract class Animal {
//具体方法
public void breathe() {
System.out.println("Animal is breathing");
}
//抽象方法:所有动物都会叫但是叫声不同,这里只申明不实现
public abstract void makeSound();
}

class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("Meow");
}
}

注意,抽象类只是不能被实例化,剩下的和普通类没什么区别,可以有构造方法、属性、具体方法等。

接口
接口是一种更纯粹的抽象形式,他只包含方法的声明,没有任何实现。一句话:接口定义了一系列方法,这个方法必须被实现接口的类所实现。

接口不可以被实例化,只能被实现。
但是接口可以实例化他的实现类。

看代码:

interface Flyable {
void fly();
}
class Bird implements Flyable {
@Override
public void fly() {
System.out.println("Bird is flying");
}
}
public class Main {
public static void main(String[] args) {
Flyable f = new Bird(); //接口引用指向实现类对象
f.fly(); //输出: Bird is flying
}
}

注意,这里解释一下Flyable f = new Bird(),很多同学可能会有疑问,为什么不定义成Bird f = new Bird()呢?答案式为了实现代码解耦,提高扩展性。下面我们看一下两种实现方式的区别:

//方式一:接口引用指向实现类对象
如果我们未来想把 f换成一个Plane类的对象,只要Plane实现了Flyable接口,我们只需要直接将new Bird()改成new Plane()即可,而不需要修改其他代码。

//方式二:具体类引用
我们需要修改所有用到f的地方,把Bird全改成Plane,改着改着就错了😥。不信可以试试

设计模式

单例模式

单例模式确保一个类只能有一个实例,并提供一个全局访问点。简单来说就是利用唯一性来优化资源利用,保证数据一致。这也就决定了单例模式的几个特性:

  1. 私有构造方法:防止外部通过new关键字创建多个实例。
  2. 私有静态变量:用于保存唯一的实例。
  3. 公有静态方法,提供一个全局访问点来获取实例。

单例模式的实现方式根据单例实例化时机的不同分为饿汉式懒汉式

饿汉式: 在类加载时就创建实例,不管是否会用到,优点是线程安全,缺点是可能造成资源浪费。

public class Stone {
private static final Stone stone = new Stone();
private Stone() {}
public static Stone getInstance() {
return stone;
}
}
public class Main {
public static void main(String[] args) {
Stone s = Stone.getInstance();
}
}

懒汉式: 在第一次调用获取实例的方法时才创建实例,优点是节省资源,缺点是需要处理线程安全问题。

public class Stone {
private static Stone stone;
private Stone() {}
public static Stone getInstance() {
if (stone == null) {
stone = new Stone();
}
return stone;
}
}
public class Main {
public static void main(String[] args) {
Stone s = Stone.getInstance();
}
}

这里具体说明一下:

线程安全问题具体指的是如果多个线程同时调用getInstance()方法,可能会创建多个实例,违背了单例模式的初衷。解决方法有很多,比如使用synchronized关键字来锁定方法:public static synchronized Stone getInstance() {...}。大家只做了解即可,现阶段还不会用到。

工厂模式

工厂模式通过定义一个接口或抽象类来创建对象,而不是直接实例化具体的类。这样可以将对象的创建逻辑与使用逻辑分离,提高代码的灵活性和可维护性。工厂模式主要有三种类型:简单工厂模式工厂方法模式抽象工厂模式。核心价值在于解耦和扩展性。

我们先来看一个没有工厂的情景:假设你需要创建不同类型的手机对象,直接在代码中new实例:

Phone Huawei = new HuaweiPhone();
Phone Apple = new ApplePhone();

问题很明显:

  1. 耦合严重:使用者必须知道具体的实现类(HuaweiPhone、Iphone),如果后续替换手机类型(如换成小米),需要修改所有new的地方;
  2. 扩展性差:新增手机类型时,需要修改所有使用方的代码,违反 “开放 / 封闭原则”。

工厂模式的作用就是解决这些问题,把对象创建的”复杂活”交给工厂,使用者只需要”提需求”。

简单工厂模式(静态工厂模式): 通过一个工厂类根据传入的参数决定创建哪种具体产品类的实例。

public class PhoneFactory{
public static Phone createPhone(String type){
if (type.equals("huawei")) {
return new HuaweiPhone();
}
else if (type.equals("...")) {
return new ...();
}
}
}

工厂方法模式:为了解决简单工厂的”职责过重”问题,工厂方法模式将”创建不同产品”的逻辑,拆分到多个具体工厂类中,每个工厂只负责创建一种产品。

interface PhoneFactory {
Phone createPhone();
}
public class HuaweiFactory implements PhoneFactory(){
@Override
public Phone createPhone(){
return new HuaweiPhone();
}
}
//Main函数中要用华为手机
PhoneFactory huaweiFactory = new HuaweiFactory();
Phone huawei = huaweiFactory.createPhone();

抽象工厂模式: 抽象工厂模式进一步将工厂方法模式进行抽象,提供一个接口用于创建一系列相关或相互依赖的对象,而无需指定它们具体的类。一个具体工厂负责创建一组相关产品。

interface ElectronicFactory {
Phone createPhone(); // 创建手机
Earphone createEarphone(); // 创建耳机
}
public class HuaweiFactory implements ElectronicFactory {
@Override
public Phone createPhone() { return new HuaweiPhone(); }
@Override
public Earphone createEarphone() { return new HuaweiEarphone(); }
}

观察者模式

观察者模式定义了一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都会得到通知并自动更新。观察者模式主要包含两个角色:主题(Subject)观察者(Observer)

主题对象只需要知道它有哪些观察者,而不需要知道每个观察者的具体实现细节。同样,观察者也只需要关注主题对象的状态变化,而不需要知道主题对象的内部逻辑。这就极大程度地实现了代码的解耦。

为了清楚的阐释观察者模式的实现方式,我们以气象站为例,温度,湿度,气压显示器为观察者。

interface Subject {
void registerObserver(Observer o); //添加观察者
void removeObserver(Observer o); //移除观察者
void notifyObservers(); //通知观察者
}
interface Observer{
void updata(float temperature,float humidity,float pressure); //观察者响应主题
}
public class WeatherData implements Subject {
private List<Observer> observers;
private float temperature;
private float humidity;
private float pressure;
public WeatherData(){
observers = new ArrayList<>();
}
@Override
public void registerObserver(Observer o) {
observers.add(o);
}

@Override
public void removeObserver(Observer o) {
observers.remove(o);
}

//这里大概率是图的知识,可以参照一下OOpre课程代码的notifyEmployees和notifyAllEmployees函数。
@Override
public void notifyObservers() {
for (Observer o : observers) {
o.update(temperature, humidity, pressure);
}
}
}
public class Table implements Observer{
//在Observer中可以和Subject建立联系(雇佣关系的帮助),也可以自己执行某些指令
@Override
public void updata(float temperature,float humidity,float pressure){
//....
}
}

递归下降法简介

递归下降法是一种自顶向下的语法分析方法,通常用于编译器和解释器中。它通过递归地调用函数来处理输入的符号序列,从而构建语法树。递归下降法的核心思想是将每个非终结符对应一个函数,这些函数根据文法规则来解析输入。一句话:递归下降法就是用函数调用模拟语法规则的推导过程。

这个说法有点抽象,大家可能云里雾里,我们看一个简单的例子:

这里我们以雇佣关系为例,A(B(E(G),F),C,D)表示,A雇佣B,C,D,B雇佣E,F,E雇佣G。

形式化表述

  • 冒险者 → 标识符 [被雇佣者]

  • 被雇佣者 → ‘(‘ 冒险者序列 ‘)’

  • 冒险者序列 → 冒险者 {‘,’ 冒险者}

  • 标识符 → 数字 | 字母 | ‘_’ [标识符]

  • 数字 → ‘0’ | ‘1’ … ‘9’

  • 字母 → ‘a’ | ‘b’ … | ‘z’ | ‘A’ | ‘B’ …| ‘Z’ 其中

  • {} 表示允许存在 0 个、1 个或多个。

  • [] 表示允许存在 0 个或 1 个。

变量说明

Token:词法单元,是指从源代码中分解出来的、具有独立语法意义的最小单元。
Lexer:词法分析器,核心功能是读取字符,输出 Token。

看代码:

public class Lexer {
//存储原始字符串
private final String input;
//标记当前解析到的位置
private int pos = 0;
//当前识别出的Token
private String curToken;

//辅助方法,删除空格
private String removeWhiteSpace(String s) {
return s.replaceAll("\\s+","");
}

//辅助方法,判断标识符是否是合法字符
private boolean isIdChar(char c) {
return Character.isLetter(c) || Character.isDigit(c) || c == '_';
}

//辅助方法,读取完整标识符
private String getId() {
StringBuilder sb = new StringBuilder(); //用于拼接标识符
while(pos < input.length() && isIdChar(input.charAt(pos))) {
sb.append(input.charAt(pos));
pos++;
}
return sb.toString(); //返回拼接好的完整标识符
}

//核心方法,读取下一个Token
public void next() {
if (pos == input.length()) {
curToken = null;
return;
}

//获取当前指针位置的字符
char c = input.charAt(pos);

if (isIdChar(c)) {
curToken = getId();
}
else if(c == '(' || c == ')' || c == ','){
pos += 1; //这里要保证pos到curToken的下一位
curToken = String.valueOf(c); //特殊字符单独作Token
}
else {
throw new RuntimeException("Unexpected Character" + c);
}
}

//初始化Lexer
public Lexer (String input) {
this.input = removeWhiteSpace(input);
next();
}

//查看当前Token,并且不移动指针
public String peek() {
return curToken;
}

//匹配目标方法
public void match(String target) {
if (target.equals(curToken)) {
next(); //如果匹配,就消耗,并准备好下一个Token
} else {
throw new RuntimeException("Excepted token :" + target + ", but got: "+ curToken);
}
}
}

以上是Lexer的完整代码,主要功能就是依次拆解出Token,然后应用到Main函数。注意一下matchpeek的区别,match自带next方法,peek只监测curToken。同时pos永远指向即将解析的Token

Main中调用时需要写一个递归函数parseEmployees来调用解析器。

private static void parseEmployees(Lexer lexer,String employer) {
while (true) {
String token = lexer.peek();
if (token == null || token.equals(')')) {
break;
}
String employee = token;
lexer.next();

//在这里建立employee和employer的关系

if('('.equals(lexer.peek())) {
lexer.match('(');
parseEmployees(lexer,employee);
lexer.match(')');
}
else if (','.equals(lexer.peek())) {
lexer.match(',');
}
}
}

递归函数退出的时候条件是),因此我们需要用match方法消耗掉这个token,从而继续向后执行。

Main:
Lexer lexer = new Lexer(input); //初始化时将pos带到了待解析位
String topEmployer = lexer.peek();
lexer.next();
lexer.match("(");
parseEmployees(lexer, topEmployer);
lexer.match(")");

面向对象编程总结

以上就是OOPre课程的核心内容简介,如果你看到这里,那么恭喜你应该可以速通OOPre,面向对象编程的思想方法是我们以后学习编程的基础,希望大家能理解并灵活运用到实际编程中。接下来我们将进入Java项目编程规范的介绍,这里我们采用阿里巴巴Java开发手册作为参考标准。


Java项目编程规范简介

命名风格

  • 类名使用大驼峰命名法,例:XmlService。
  • 方法名,参数名,变量名使用小驼峰命名法,例:getUserName。
  • 常量名使用全大写字母,单词之间用下划线分隔,例:MAX_SIZE。
  • 中括号是数组定义的一部分,不能与类型分开,例:int[] arr。
  • 在POJO类中,布尔类型变量不要使用is开头,例:boolean active; 而不是 Boolean isActive;。
  • 接口类中的方法属性不要任何修饰变量,public也不要加。

代码格式

  • 大括号为空写成{},不换行。大括号非空时写法参考代码示例。
  • 左/右小括号和字符之间不要有空格。
  • if/for/while/switch等关键字与左小括号之间要有一个空格。
  • 任何二目,三目运算符前后都要有空格。
public static void main(String[] args) {
String str = "Hello World"; // 二目运算符前后有空格
int flag = 0;
// if关键字与左小括号之间有空格,括号内 f 与左括号, 0 与右括号之间没有空格
if (flag == 0) {
System.out.println(str);
} else {
System.out.println("Flag is not zero");
}
}
  • 单行字符限制不超过120个字符,超过时需要换行。换行规则如下:

    1. 第二行相对第一行缩进4个空格,从第三行开始不再继续缩进。
    2. 运算符与上下文一起换行。
    3. 方法调用的点符号与下文一起换行。
    4. 如果多个参数超长,在逗号后换行,逗号与下文一起换行。
    5. 括号前不要换行。
  • 当传入多个参数时,每个参数的逗号后面要加空格。

面向对象变成规范

  • 类内方法定义的顺序是:公有方法或保护方法 > 私有方法 > getter/setter方法。
  • getter/setter方法内不增加业务逻辑,只return this.data

Lyrics Shring

我只想要拉住流年
好好的说声再见
遗憾感谢都回不去昨天
我只想铭记这瞬间
我们一起走过的光年
6月后 光年成纪念

未完待续

虽然 OOpre 的征程告一段落了,但是这篇写于 2025 年 1 月的文章还没有结束,笔者会在后续的学习中完善 Java 项目编程规范的内容,欢迎大家持续关注


文章作者: Cordial-Kid
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Cordial-Kid !
  目录