OO第二单元总结——多线程

朱雄伟-22373498 学生 2024-04-18 18:36:58

OO第二单元总结博客

写在前面

OO的第二单元终于结束了,好难啊!!!!!

本单元的代码编程量明显有大幅度下降,但是更多地需要思维分析,突破过去习惯的单线程思想,尝试用并行思维思考代码怎么写。同时,线程安全问题和CPU运行时间控制问题在编写代码时也是两个不容忽视的难点。

第五次作业

我们要模拟的电梯系统是一个类似北京航空航天大学新主楼的电梯系统,楼座内有多部电梯,电梯可以在楼座内1-11层之间运行。系统从标准输入中读入乘客请求信息(起点层,终点楼层),请求调度器会根据此时电梯运行情况(电梯所在楼层,运行方向等)将乘客请求合理分配给某部电梯,然后被分配请求的电梯会经过上下行,开关门,乘客进入/离开电梯等动作将乘客从起点层运送到终点层。

本次作业的UML类图与时序图如下:

简单来说,我在本次作业中采用的是生产者-消费者模式,“我来做、你来用”,由InputThread生产需求,经过Scheduler分派到对应的Elevator来执行需求。

对于一个Elevator,在获取到需求之后该如何进行运行呢?我采用的是LOOK算法:

  • 首先为电梯规定一个初始方向,然后电梯开始沿着该方向运动。

    (你想想电梯一开始在1楼,初始方向总不能是向下?)

  • 到达某楼层时,首先判断是否需要开门

    • 如果发现电梯里有人可以出电梯(到达目的地),则开门让乘客出去;

    • 如果发现该楼层中有人想上电梯,并且目的地方向和电梯方向相同,则开门让这个乘客进入。

      先下后上不仅是传统美德,更可以通过让更多的人先上电梯更简单的满足更多用户的需求(不然就要上下上)

  • 接下来,进一步判断电梯里是否有人。如果电梯里还有人,则沿着当前方向移动到下一层。否则,检查请求队列中是否还有请求(目前其他楼层是否有乘客想要进电梯):

    • 如果请求队列不为空,且某请求的发出地是电梯"前方"的某楼层,则电梯继续沿着原来的方向运动。

    • 如果请求队列不为空,且所有请求的发出地都在电梯"后方"的楼层上,或者是在该楼层有请求但是这个请求的目的地在电梯后方(因为电梯不会开门接反方向的请求),则电梯掉头并进入"判断是否需要开门"的步骤(循环实现)。

    • 如果请求队列为空,且输入线程没有结束(即没有输入文件结束符),则电梯停在该楼层等待请求输入(wait)。

    • 如果请求队列为空,且输入线程已经结束,则电梯线程结束。

数据结构

作业中涉及到两个数据类:Person和RequesetQueue。Person类存储了我们从输入中获取到的用户相关信息。

 // Person.java
 ​
 public Person(int personId, int start, int finish, int elevatorId) {
     this.personId = personId;
     this.start = start;
     this.finish = finish;
     this.elevatorId = elevatorId;
     this.length = finish - start; // 到达楼层在出发楼层的上面还是下面?
 }

RequestQueue是多个线程之间的共享对象,存有许多的Person。为了保证其线程安全,需要加上synchronized修饰。

 // RequestQueue.java
 ​
 public class RequestQueue {
     private ArrayList<Person> queue = new ArrayList<>();
     private boolean isEnd = false;
 ​
     public ArrayList<Person> getQueue() {
         return queue;
     }
 ​
     public synchronized Person getFirstPerson() {
         if (queue.isEmpty() && !isEnd) {
             try {
                 wait();
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }
         if (queue.isEmpty()) {
             return null;
         }
         Person person = queue.get(0);
         queue.remove(0);
         notifyAll();
         return person;
     }
 ​
     public synchronized boolean isEmpty() {
         notifyAll();
         return queue.isEmpty();
     }
 ​
     public synchronized void addPerson(Person person) {
         queue.add(person);
         notifyAll();
     }
 ​
     public boolean isEnd() {
         return isEnd;
     }
 ​
     public synchronized void setEnd() {
         isEnd = true;
         notifyAll();
     }
 ​
 }

这里为了保险,我给大部分方法都加上了synchronized,少数方法在代码实现中也提前用同步块来修饰。由于请求是实时发送的,所以请求空了 ≠ 请求结束,它可能隔了很久很久才有一个请求,那么在这个请求之前只能让线程等待(请求为空),但不能让线程结束(请求结束)。

运行架构

整个项目设计三类线程:InputThread、Scheduler和Elevator。从名字上很容易知道他们是干什么的

InputThread持有一个总表,从输入中读取输入,并将其包装为Person对象。

 // InputThread.java
 public class InputThread extends Thread {
     private final RequestQueue totalQueue;
 ​
     @Override
     public void run() {
         ElevatorInput elevatorInput = new ElevatorInput(System.in);
         while (true) {
             PersonRequest request = elevatorInput.nextPersonRequest();
             if (request == null) {
                 // 都读完了,下班!
                 break;
             } else {
                 Person person = new Person(
                         // ^_^
                 );
                 totalQueue.addPerson(person);
             }
         }
         try {
             totalQueue.setEnd();
             elevatorInput.close();
         } catch (IOException e) {
             throw new RuntimeException(e);
         }
     }
 }

Scheduler线程持有所有电梯的需求队列,与InputThread共享总表。

 // Scheduler.java
 public class Scheduler extends Thread {
     private final RequestQueue totalQueue;
     private final ArrayList<RequestQueue> queues;
     private final ArrayList<Elevator> elevators;
 ​
     public int bestElevator(Person person) {
         // 这次你指望写出什么高级分配方法?
         return person.getElevatorId() - 1;
     }
 ​
     @Override
     public void run() {
         while (true) {
             synchronized (totalQueue) {
                 if (totalQueue.isEmpty() && totalQueue.isEnd()) {
                     // 下班!也要告诉电梯们都下班了!
                     return;
                 }
             }
             Person person = totalQueue.getFirstPerson();
             if (person == null) {
                 continue;
             }
             // 将需求加入合适电梯的需求队列里
             queues.get(bestElevator(person)).addPerson(person);
         }
     }
 }
 ​

电梯拥有许多不同的调度方法,具体实现就在bestElevator中进行。当然,这次作业中需求制定了需要服务的电梯,不过把这个方法留在这里,下次写调度方法的时候需要改的地方就比较少。

Elevator类中实现了电梯类与策略类的分离。运行方向是电梯的一个状态量而不是过程量,用来表示下一次move时的方向。当有新请求进入请求队列时,电梯被唤醒,此时电梯的运行方向仍然是电梯wait前的方向。

策略类获取当前电梯和请求队列的状态,并基于LOOK策略向电梯提供建议。

 // Strategy.java
 public class Strategy {
     // 不写注释了,看函数名就看得懂是啥意思
     public Advice getAdvice(int location, int direction,
                             ArrayList<Person> passengers, RequestQueue queue) {
         if (someoneOut(location, passengers)
                 || someoneIn(direction, location, passengers.size(), queue)) {
             return Advice.OPEN;
         } else if (!passengers.isEmpty()) {
             return Advice.MOVE;
         } else if (!queue.isEmpty()) {
             if (requestAhead(location, direction, queue)) {
                 return Advice.MOVE;
             } else {
                 return Advice.REVERSE;
             }
         } else if (queue.isEnd()) {
             return Advice.OVER;
         } else {
             return Advice.WAIT;
         }
     }
 }

在电梯类里,我好像实现了量子电梯,简要来说就是只要有乘客加入就开门,从原先的运动状态转移为开门进客状态。之所以叫量子电梯,是因为我们在这里对电梯的实现中,是以“状态机”方式来实现,电梯它又没有真的运动!

简要来说,量子电梯的原理大概如下:

当然,在最后的测试当中,量子电梯带来的帮助并不大。我采访了几位同样使用量子电梯的同学,其中有位同学取得了比较不错的效果,他的实现大概如下。

 // ElevatorThread.java
 private void QuantMove() {
     while (System.currentTimeMillis() < beginTime + 400) {
         //如果下一个状态可开门进客,则返回run()
         if (nextStatus == Status.IN) {
             return;
         }
     }
 ​
     //...
     this.elevator.Move();
 }
 ​
 private void InPassenger() {
     //...
     Status nextStatus;
     synchronized (this.requestQueue) {
         // 正常的进客
         // 利用wait等待可能的进客
         this.WaitLeftTime(openTime);
         nextStatus = Strategy.GetAdvice(this.elevator, this.requestQueue);
     }
     if (nextStatus == Status.IN) {
         return;
     }
 ​
     // 开了一定要关
     this.elevator.CloseDoor();
 }

第六次作业

本次作业与第五次作业的不同在于:

  • 用户没有指定电梯,需要我们自行调度,完成调度之后需输出RECEIVE

  • Reset指令可以改变电梯的速度与容量

本次作业的UML类图如下,时序图与上次作业类似。

数据结构

为了维持架构的“简洁性”(它真的简洁吗?),我们可以把人员请求和重置请求都看成“请求”,是同一个接口的两个实现。

指导书说要“接收到重置指令的电梯必须在两次移动楼层操作内将所有乘客放出”,“接收到重置指令后尽快完成重置动作”。假设Reset与Person一样,经过InputThread和Scheduler两级,可能就会导致ARRIVE次数过多。这需要我们用特别的方法让电梯赶紧知道需要Reset。

联想计组实验中的全速转发(啥玩意),我们可以对Reset进行转发操作,由InputThread直接传输到对应的Elevator中。

 // InputThread.java
 @Override
 public void run() {
     ElevatorInput elevatorInput = new ElevatorInput(System.in);
     while (true) {
         Request request = elevatorInput.nextRequest();
         if (request == null) {
             break;
         } else if (request instanceof PersonRequest) {
             // 乘客需求
         } else if (request instanceof ResetRequest) {
             // 重置需求
             Reset reset = new Reset(
                     eid,
                     request1.getCapacity(),
                     (int) velocityD
             );
             queues.get(eid - 1).addRequestFront(reset);
         }
     }
     // ^_^
 }

运行架构

对于Reset请求,我们在Strategy策略类里也需要做一些修改。

 // Strategy.java
 public Advice getAdvice(int location, int direction, int capacity,
                         ArrayList<Person> passengers, RequestQueue queue) {
     if (shouldReset(queue)) {
         return Advice.RESET;
     }
     if (someoneOut(location, passengers)
             || someoneIn(direction, location, capacity, passengers.size(), queue)) {
         return Advice.OPEN;
     } else if (!passengers.isEmpty()) {
         return Advice.MOVE;
     } else if (!queue.isEmpty()) {
         if (requestAhead(location, direction, queue)) {
             return Advice.MOVE;
         } else {
             return Advice.REVERSE;
         }
     } else if (queue.isEnd()) {
         return Advice.OVER;
     } else {
         return Advice.WAIT;
     }
 }
 ​

只要有Reset请求,电梯里就开始进行重置操作。这里,我们可以参考指导书里的提示“中途下电梯的乘客和从起始位置出发的乘客对程序而言有区别吗?”“重置请求是否可以认为是电梯运行逻辑中的一个动作(类似开关门、上下行)?”

// Elevator.java
public void doReset() throws InterruptedException {
    // 将电梯设置为重置状态
    // 把电梯里的乘客全赶出去
    // 如果乘客还没到达目的地,就修改其起点为此时的停靠楼层,丢回总表
    passengers.clear();
    TimableOutput.println("RESET_BEGIN-" + elevatorId);
    synchronized (privateQueue) {
        // 把本已分配的需求也全部丢回总表
    }
    if (reset == null) {
        return;
    }
    // 重新设置电梯的速度与容量
    sleep(1200);
    TimableOutput.println("RESET_END-" + elevatorId);
    // 取消重置状态,可以接受请求
}

前电梯可能将已分配的需求打回到总表中,那么总表中的结束条件就需要更改,简单来说,需要添加上“所有Reset请求均已处理完毕”这一条件。通过CheckTool对象的add和sub操作来维护Reset请求的数量。

影子电梯

本次作业中最重要的部分就在于乘客需求的分配。分配的方法大概有以下几种:

  1. 平均分配

    这种方法最简单,只需要index = index % 6 + 1即可。

  2. 随机分配

    index = new Random().nextInt(6) + 1

从我的尝试来看,这两种实现方法的上限很高,下限很低。因为这两种实现方法没有考虑到电梯的状态,极端的条件下甚至会造成超时。

考虑到结合电梯的当前状态,也有两种分配的方法:

  1. 评价分配

    以现有载客量、当前楼层、当前方向等等因素作为输入,以综合得分作为输出,得分最高的电梯作为分配对象。也就是说需求分配问题可以建立一个评价模型,可以通过实验来调整各个输入的权重。

  2. 影子电梯

    在输入线程获得一个请求时,将电梯的状态取出进行模拟,从而将请求分配各所花费时间最少的电梯。

我采用了影子电梯的方式,在接受请求时迅速模拟当前电梯系统,判断由哪个电梯接受请求并完成所有请求可以使得整个电梯系统的总时间消耗总电力消耗最短。在Elevator类中也同时需要实现同步方法,获取电梯的当前状态,以对系统进行正确的模拟。

由于在实现分配的过程中综合考虑了时间与电力两个因素,我在强测中取得了一个比较不错的分数。

// Scheduler.java
public void run() {
    while (true) {
        // ^_^
        } else if (singleRequest instanceof Person) {
        // 由Dispatcher得到最佳电梯编号
            Person person = (Person) singleRequest;
            int eid;
            do {
                eid = dispatcher.getQueueId(person);
                if (eid == -1) {
                    try {
                        synchronized (sharedLock) {
                            sharedLock.wait();
                        }
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            } while (eid == -1);
            String st = Integer.toString(eid + 1);
            TimableOutput.println("RECEIVE-" + person.getPersonId() + "-" + st);
            queues.get(eid).addRequest(person);
        }
    }
}

我使用ResetLock对象获取电梯是否正在处于重置状态中,如果电梯在Reset,那么就不会把需求分给这个电梯。

这里具体看看Dispatcher是怎么实现的。SimElevator是专门用于影子电梯模拟的一个功能类,内部方法与真正的Elevator其实没有太大的区别,只是将电梯里的sleep方法都换成了累加。

// Dispatcher.java
public int getQueueId(Person person) {
    Person target = person.getSame();
    int choice = -1;
    long minScore = Long.MAX_VALUE;
    // 取出六部电梯的当前状态、如需求队列、乘客、位置、方向等。
    for (int i = 0; i < 6; i++) {
        // 跳过正在Reset的电梯
        long totalTime = 0;
        long totalElec = 0;
        long maxTime = Long.MIN_VALUE;
        // 给选定的电梯设置目标
        for (SimElevator elevator : tempElevators) {
            // 对整个电梯系统进行模拟
            // 统计整个电梯系统的总时间消耗与总电力消耗
        }
        // 维护最佳的选择
    }
    return choice;
}

第七次作业

本次作业增加的内容为:电梯可以进行有丝分裂(或者叫减数分裂?),一分为二;完成这样的操作之后,一部电梯在其所在的电梯井里就拥有了一部“姐妹电梯”。同样的,双轿厢重置请求也可以看作是“请求”的一种,在实现数据结构上只需要实现对应的接口即可。

本次作业的UML类图与时序图与第六次作业类似,故不再赘述。总的来看,我的架构在三次作业中没有发生太大的变化,均是由“InputThread生产需求-Scheduler分配需求-Elevator满足需求”组成。

运行架构

怎么维护电梯井里的两个轿厢呢?一开始,我就创建了12个电梯线程。每个电梯线程除了持有自己的请求序列之外,也同时持有同一电梯井的另一部电梯的请求序列。当启动DCReset的时候,我们就启动他的姐妹电梯。

// InputThread.java
public void run() {
    ElevatorInput elevatorInput = new ElevatorInput(System.in);
    while (true) {
        Request request = elevatorInput.nextRequest();
        if (request == null) {
            break;
        } else if (request instanceof PersonRequest) {
            // 乘客请求
        } else if (request instanceof NormalResetRequest) {
            // 普通重置请求
        } else if (request instanceof DoubleCarResetRequest) {
            // 双轿厢重置请求
        }
    }
    // ^_^
}

这里的“启动”,指的是设置电梯的状态为“可运行”,并将其标识设置为“B”,原来的电梯设置标识“A”。

关键问题在两部电梯不能同时出现在换乘层。我的思路是将电梯进入换乘层视作一个“原子操作”,电梯必须连续完成“到达-下客-上客-离开”的操作。

// Elevator.java
private void move() throws InterruptedException {
    long now = System.currentTimeMillis();
    while (System.currentTimeMillis() - velocity < now) {
        Advice nextAdvice;
        synchronized (privateQueue) {
            privateQueue.wait(velocity);
            nextAdvice = strategy.getAdvice(location, direction,
                    capacity, passengers, privateQueue);
            privateQueue.notifyAll();
        }
        if (nextAdvice == Advice.OPEN) {
            return;
        }
    }
    location = location + direction;
    if (location == transfer && !type.isEmpty()) {
        // 这个电梯是双轿厢电梯,且已经到达换乘层
        try {
            transferControl.writeLock().lock();
            TimableOutput.println("ARRIVE-" + location + "-" + elevatorId + type);
            // 先下后上,小心踩踏(bushi)
            // 赶紧离开换乘层
            TimableOutput.println("ARRIVE-" + location + "-" + elevatorId + type);
        } finally {
            transferControl.writeLock().unlock();
        }
        return;
    }
    TimableOutput.println("ARRIVE-" + location + "-" + elevatorId + type);
}

使用一个WriteLock来控制临界区,当一个电梯在换乘层时,它持有这个写锁,此时假设另一个电梯也想进入换乘层,它就会被阻塞,直到这个电梯离开换乘层。注意这里使用finally语句,保证写锁能被正确的解锁,防止死锁产生。这样的实现方式可以保证当两部电梯都有进入换乘层的需求时,当一部电梯

电梯的分配也需要做一些修改。在Dispatcher进行分类时,我将两部电梯看作“一部”电梯,实现静态的分配,具体来说就是:假设某位乘客需要从11层到1层,调度器决定分配给1号电梯,1B电梯到达换乘层时乘客下电梯,此时即使其他电梯在性能上更占优,该乘客依然会选择1A电梯。

对于上一次作业完成的模拟方法,我们只需要做一些微小的修改。

// Dispatcher.java
private void addTarget(Person target, int id) {
    int t = tempElevators.get(id).getTransferFloor();
    if (// 电梯是双轿厢电梯,且目标需求跨越换乘层,需要两部电梯协作处理) {
        if (// 起点在换乘层的下方) {
            // ^_^
        } else if (// 起点在换乘层的上方) {
            // ^_^
        }
    } else if (// 双轿厢电梯,但只需要一部电梯处理需求) {
        // 分给A还是B?
    } else {
        // 单轿厢电梯
    }
}

互测bug

本单元的强测均未出现bug。

第五次作业未被hack。

第六次作业被hack11次(家人们谁懂啊),问题主要出在电梯在Reset状态下的乘客需求分配上。假设我们有这样的需求:

[2.6]RESET-Elevator-1-3-0.6
[40.0]RESET-Elevator-2-3-0.6
[40.0]RESET-Elevator-3-3-0.6
[40.0]RESET-Elevator-4-3-0.6
[40.0]RESET-Elevator-5-3-0.6
[40.0]RESET-Elevator-6-3-0.6
[40.6]1-FROM-1-TO-11
[40.6]2-FROM-1-TO-11
[40.6]3-FROM-1-TO-11
[40.6]4-FROM-1-TO-11
[40.6]5-FROM-1-TO-11
[40.6]6-FROM-1-TO-11
[40.6]7-FROM-1-TO-11
[40.6]8-FROM-1-TO-11
[40.6]9-FROM-1-TO-11
// 省略一堆从1到11的需求

那么,按照我原来的设计,40.6s时只有1号电梯可以运行,导致所有的请求都被分配到了1号电梯上,造成超时。

面对这个问题,我进行了以下调整:

  • 在分配器Dispatcher中,将分配指标改为“电梯最大运行时间”,这样可以使得这些需求被均匀分配

  • 只要某一部电梯在Reset,就暂停所有乘客请求的分配。这虽然看起来慢了一点,但是它确实慢了一点

    当然也只是一点点,为了正确性做一点牺牲也不是很亏,而且它也不见得都是慢,正所谓越慢越快、越快越慢

    (man!what can I say!)

第七次作业被hack三次,主要问题是在于可能存在的线程不安全问题被触发,导致A电梯遁地/B电梯飞天等情况。

将临界区适当扩大,保证属性完全修改并可见之后在释放锁,应该能解决这些问题。

测试

Hack策略

我在这个单元还是以黑箱测试为主,主要检查线程安全问题。对于黑箱测试产生问题的代码,我会阅读寻找其线程不安全的地方,并且构造针对性的样例来警醒测试。这个单元我hack了11次,大部分同学问题都是出现在Reset条件下乘客请求的分配上。

CTLE问题

虽然指导书里有写“代码实现中不存在轮询。轮询是一种非常占用 CPU 资源的行为,为了避免出现轮询的情况,请使用 Wait-Notify 的方式编程。同时,为了检查是否出现轮询的情况,总 CPU 时间限制为 10s,不满足该限制的程序会被判定为错误。”但是,基于“不会写”“懒得改”等多条原因,我的代码当中一开始也出现了许多轮询的场景。直到我发现区区15条输入居然消耗了2.5s的CPU时间后,我才下定决心全改成了Wait-Notify。

那么怎么测试自己的代码是否出现了轮询呢?我使用的是IDEA自带的Profiler工具,在程序完成运行后可以找到热点函数,判断是否存在“轮询”“线程无法结束”等问题并进行改进。虽然本地运行的结果往往与评测机上的结果存在一些差异,但是大概在哪些方法上运行时间较长是可以发现的。

Debug

多线程程序的Bug其实很难复现。以线程无法正常结束为例,简要说说我的debug方法。

  • 最简易的方法就是print大法,尝试多次相同数据,在每个setEnd和isEnd方法均加上打印语句,观察线程是否被正确唤醒。

  • 结合print产生的问题,采用走查的方式,判断是哪一个步骤导致线程无法正常结束。

  • 最后使用多个类似的测试点反复实验,观察错误是否依然存在。

数据生成&正确性检验

我在本单元中(主要是前两次作业)完成了数据生成和正确性检验的相关工作,这可以帮助我对我的代码进行黑盒测试,以便更快的找到bug。

正确性检验程序尤其难写,需要判断各种指令是否会导致异常,同时还要注意时间戳之间的约束是否满足。模拟过程中,不断地取出一条指令改变一部电梯的状态,同时判断该状态改变是否合法。

总结

总的来看,我在完成三次作业中架构没有发生太大的变化,这得益于我在开始写代码之前对整体架构进行了一个比较好的设计。在Strategy类、InputThread类、Scheduler类上,我留足了充分的扩展空间,可以满足后面作业的迭代需求。

我觉得我本单元最大的不足在于混淆了电梯与电梯线程,电梯的诸多行为分散在电梯线程中,迭代起来修改非常困难,其实应该每个电梯线程持有一个私有的电梯对象,将电梯的行为封装到电梯的对象里,实现低耦合。

线程安全是多线程最重要的一个议题,我们应该怎么保证线程之间的数据是稳定的,也就是说,一个类要成为线程安全,必须要保证无论什么运行环境,无论这些线程按照怎样的时序安排交错,他都能按照我们希望的方式流转在各个线程之间,不会出现数据污染。我在设计的时候重点标注了共享量,在完成代码的时候时刻注意共享变量更新时的线程安全问题。当然在进行review的时候我也发现了许多不足,比如应该将Person、Reset等请求设置为不可变对象,这样可以防止某些行为导致对象内部不可预知的变化,造成程序运行上的错误。

平心而论,我认为最后的代码还存在着许多线程不安全的地方。就当是买了个教训,下次有类似的需求再努力吧。

写在最后

第二单元总体来说是非常有挑战的,但是同样收获也非常多,学习了多线程、锁、线程安全、读写锁等新知识点。

同时我也领悟到了“片面最佳!=全局最佳”等道理(公式做题就是快),不能为了性能就冒险失去了正确性,每一步的贪心不一定(往往不)能带来全局的最佳。结合“鱼越大鱼越小”理论,我把这些观点总结为越快越慢、越慢越快orz。

以上观点仅供参考,每个人都有不同的实现方式,尽善尽美并不容易,尽力而为便够了。

...全文
202 3 打赏 收藏 转发到动态 举报
写回复
用AI写文章
3 条回复
切换为时间正序
请发表友善的回复…
发表回复
魏新明22373300 学生 2024-04-21
  • 打赏
  • 举报
回复

img

张奕彤-22373337 学生 2024-04-19
  • 打赏
  • 举报
回复

img

卞卓航-22373017 学生 2024-04-19
  • 打赏
  • 举报
回复

img

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧