BUAA_OO_UNIT3 总结

一、JML语言基础梳理

  • JML(Java Modeling Language,Java建模语言),在Java代码种增加了一些符号,这些符号用来标志一个方法是干什么的,但是不关心它的具体实现。通过使用JML,在实现代码前,我们可以描述一个方法的预期功能,而尽可能地忽略实现,从而把过程性思考一直延迟到方法设计的层面。

  • 如果仅仅是描述方法的功能,那么自然语言一样可以做到,但是,使用JML语言的好处是,相比于容易产生歧义的自然语言,以前置条件、副作用、异常行为、作用域、后置条件等为标准规格的JML规格语言能减少歧义的产生。

  • 一段简易的jml代码示例如下:

    /*@ public normal_behavior(注:一般行为)
      @ requires class != null;(注:前置条件)
      @ assignable \nothing;(注副作用)
      @ ensures \result = (class == nowclass)(注:后置条件、\result-返回值)
      @ also
      @ public exceptional_behavior(注:异常行为)
      @ signals (ClassNotValid e) class == null;(注:抛出异常)
   */
  • 当然,是语言就会有问题,但是,严格的JML语言避免了本身的歧义,一旦出现问题,就很容易能找到是JML规格的描述问题还是代码的实现问题。对用户而言,能提早发现用户代码对类地错误使用,还能给用户提供和代码配套的JML规格文档;而对于程序员,能够准确地知道代码需要实现的功能,高效地寻找和修正程序的bug(对比代码和规格便知),还能在代码升级时降低引入bug的风险。

二、架构与设计分析

1.数据结构

  • 根据JML的描述,很容易选择JML描述中的列表等数据结构,这样的结果就是正确性得到了保证,然而程序的效率相当低下(或许这也是jml的一个坑?)。
  • 有此思量,在进行数据结构的选择时,我尽可能使用索引(即Map)结构:

JML描述(以Person为例)

    /*@ public instance model int id;
      @ public instance model non_null String name;
      @ public instance model int age;
      @ public instance model non_null Person[] acquaintance;
      @ public instance model non_null int[] value;
      @ public instance model int money;
      @ public instance model int socialValue;
      @ public instance model non_null Message[] messages;
      @*/

代码实现

    private final int id;
    private final String name;
    private final int age;
    //  Person - Value
    private final HashMap<Person,Integer> linkedPersonMap;
    private final Deque<Message> messageList;

    private int money;
    private int socialValue;

2.算法考量

1.isCircle

jml描述

点击查看代码
/*@ public normal_behavior
      @ requires contains(id1) && contains(id2);
      @ ensures \result == (\exists Person[] array; array.length >= 2;
      @                     array[0].equals(getPerson(id1)) &&
      @                     array[array.length - 1].equals(getPerson(id2)) &&
      @                      (\forall int i; 0 <= i && i < array.length - 1;
      @                      array[i].isLinked(array[i + 1]) == true));
      @ also
      @ public exceptional_behavior
      @ signals (PersonIdNotFoundException e) !contains(id1);
      @ signals (PersonIdNotFoundException e) contains(id1) && !contains(id2);
      @*/
	  public /*@ pure @*/ boolean isCircle(int id1, int id2) throws PersonIdNotFoundException;

显然是查询两个节点是否连通,为了效率考量,我选择并查集实现;同时,为了代码复用与编写方便,我将并查集结构进行了封装

public class DisJointSet {
    private final HashMap<PersonPro, PersonPro> fatherMap;	//	子节点->父节点 映射
    private final HashMap<PersonPro, Integer> rankMap;	//	优先级集合
    private int blockSum;	//	保存block数,方便进行查询

    public DisJointSet() {
        this.fatherMap = new HashMap<>();
        this.rankMap = new HashMap<>();
        this.blockSum = 0;
    }

    public void addPerson(PersonPro personPro) {
        this.fatherMap.put(personPro,personPro);
        this.rankMap.put(personPro, 0);
        this.blockSum++;	//	每增加一个Person,便会增加一个block
    }

    public void merge(PersonPro personPro1, PersonPro personPro2) {
        PersonPro root1 = findParent(personPro1);
        PersonPro root2 = findParent(personPro2);
        if (!root1.equals(root2)) {
		/*	每进行一次非同源节点的合并
		(即两个节点原本不在同一个block中,但是此时合并两个block)
		,block数减1 */
            blockSum--;
            if (rankMap.get(root1) < rankMap.get(root2)) {
                fatherMap.put(root1,root2);
            } else {
                fatherMap.put(root2, root1);
                if (rankMap.get(root1).equals(rankMap.get(root2))) {
                    rankMap.put(root1,rankMap.get(root1) + 1);
                }
            }
        }
    }

    public PersonPro findParent(PersonPro rootPerson) {
        PersonPro tempPerson = rootPerson;
        PersonPro tempRoot = tempPerson;
        while (!tempRoot.equals(fatherMap.get(tempRoot))) {
            tempRoot = fatherMap.get(tempRoot);
        }
        //  开始路径压缩
        while (!tempPerson.equals(tempRoot)) {
            PersonPro preFather = fatherMap.get(tempPerson);
            fatherMap.put(tempPerson,tempRoot);
            tempPerson = preFather;
        }
        return tempRoot;
    }

    public boolean isCircle(PersonPro p1, PersonPro p2) {
        return findParent(p1).equals(findParent(p2));
    }

    public int getBlockSum() {
        return blockSum;
    }
}

2.queryLeastConnection

jml描述

点击查看代码
/*@ public normal_behavior
      @ requires contains(id);
      @ ensures \result ==
      @         (\min Person[] subgroup; subgroup.length % 2 == 0 &&
      @           (\forall int i; 0 <= i && i < subgroup.length / 2; subgroup[i * 2].isLinked(subgroup[i * 2 + 1])) &&
      @           (\forall int i; 0 <= i && i < people.length; isCircle(id, people[i].getId()) <==>
      @             (\exists int j; 0 <= j && j < subgroup.length; subgroup[j].equals(people[i]))) &&
      @           (\forall int i; 0 <= i && i < people.length; isCircle(id, people[i].getId()) <==>
      @             (\exists Person[] connection;
      @               (\forall int j; 0 <= j && j < connection.length - 1;
      @                 (\exists int k; 0 <= k && k < subgroup.length / 2; subgroup[k * 2].equals(connection[j]) &&
      @                   subgroup[k * 2 + 1].equals(connection[j + 1])));
      @                connection[0].equals(getPerson(id)) && connection[connection.length - 1].equals(people[i])));
      @           (\sum int i; 0 <= i && i < subgroup.length / 2; subgroup[i * 2].queryValue(subgroup[i * 2 + 1])));
      @ also
      @ public exceptional_behavior
      @ signals (PersonIdNotFoundException e) !contains(id);
      @*/
    public /*@ pure @*/ int queryLeastConnection(int id) throws PersonIdNotFoundException;

实际就是在询问以personId值为id的Person所在block的最小生成树,考虑到本次作业的图大多为稀疏图,我选择使用Kruskal算法,结合并查集,实测对比中发现,其较Prim算法效率会优秀很多。具体实现过程略。

3.sendIndirectMessage

jml描述

点击查看代码
/*@ public normal_behavior
      @ requires containsMessage(id) && getMessage(id).getType() == 0 &&
      @         !isCircle(getMessage(id).getPerson1().getId(), getMessage(id).getPerson2().getId());
      @ ensures \result == -1;
      @ also
      @ public normal_behavior
      @ requires containsMessage(id) && getMessage(id).getType() == 0 &&
      @          isCircle(getMessage(id).getPerson1().getId(), getMessage(id).getPerson2().getId());
      @ assignable messages, emojiHeatList;
      @ assignable getMessage(id).getPerson1().socialValue, getMessage(id).getPerson1().money;
      @ assignable getMessage(id).getPerson2().messages, getMessage(id).getPerson2().socialValue, getMessage(id).getPerson2().money;
      @ ensures !containsMessage(id) && messages.length == \old(messages.length) - 1 &&
      @         (\forall int i; 0 <= i && i < \old(messages.length) && \old(messages[i].getId()) != id;
      @         (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i]))));
      @ ensures (\exists Person[] pathM;
      @         pathM.length >= 2 &&
      @         pathM[0].equals(\old(getMessage(id)).getPerson1()) &&
      @         pathM[pathM.length - 1].equals(\old(getMessage(id)).getPerson2()) &&
      @         (\forall int i; 1 <= i && i < pathM.length; pathM[i - 1].isLinked(pathM[i]));
      @         (\forall Person[] path;
      @         path.length >= 2 &&
      @         path[0].equals(\old(getMessage(id)).getPerson1()) &&
      @         path[path.length - 1].equals(\old(getMessage(id)).getPerson2()) &&
      @         (\forall int i; 1 <= i && i < path.length; path[i - 1].isLinked(path[i]));
      @         (\sum int i; 1 <= i && i < path.length; path[i - 1].queryValue(path[i])) >=
      @         (\sum int i; 1 <= i && i < pathM.length; pathM[i - 1].queryValue(pathM[i]))) &&
      @         \result==(\sum int i; 1 <= i && i < pathM.length; pathM[i - 1].queryValue(pathM[i])));
      @ ensures \old(getMessage(id)).getPerson1().getSocialValue() ==
      @         \old(getMessage(id).getPerson1().getSocialValue()) + \old(getMessage(id)).getSocialValue() &&
      @         \old(getMessage(id)).getPerson2().getSocialValue() ==
      @         \old(getMessage(id).getPerson2().getSocialValue()) + \old(getMessage(id)).getSocialValue();
      @ ensures (\old(getMessage(id)) instanceof RedEnvelopeMessage) ==>
      @         (\old(getMessage(id)).getPerson1().getMoney() ==
      @         \old(getMessage(id).getPerson1().getMoney()) - ((RedEnvelopeMessage)\old(getMessage(id))).getMoney() &&
      @         \old(getMessage(id)).getPerson2().getMoney() ==
      @         \old(getMessage(id).getPerson2().getMoney()) + ((RedEnvelopeMessage)\old(getMessage(id))).getMoney());
      @ ensures (!(\old(getMessage(id)) instanceof RedEnvelopeMessage)) ==> (\not_assigned(people[*].money));
      @ ensures (\old(getMessage(id)) instanceof EmojiMessage) ==>
      @         (\exists int i; 0 <= i && i < emojiIdList.length && emojiIdList[i] == ((EmojiMessage)\old(getMessage(id))).getEmojiId();
      @          emojiHeatList[i] == \old(emojiHeatList[i]) + 1);
      @ ensures (!(\old(getMessage(id)) instanceof EmojiMessage)) ==> \not_assigned(emojiHeatList);
      @ ensures (\forall int i; 0 <= i && i < \old(getMessage(id).getPerson2().getMessages().size());
      @          \old(getMessage(id)).getPerson2().getMessages().get(i+1) == \old(getMessage(id).getPerson2().getMessages().get(i)));
      @ ensures \old(getMessage(id)).getPerson2().getMessages().get(0).equals(\old(getMessage(id)));
      @ ensures \old(getMessage(id)).getPerson2().getMessages().size() == \old(getMessage(id).getPerson2().getMessages().size()) + 1;
      @ also
      @ public exceptional_behavior
      @ signals (MessageIdNotFoundException e) !containsMessage(id) ||
      @         containsMessage(id) && getMessage(id).getType() == 1;
      @*/
    public int sendIndirectMessage(int id) throws
            MessageIdNotFoundException;

本条指令实则就是在询问最短路径;选择dijkstra算法即可;考虑到数据量较大,于是我对dijkstra算法的实现过程进行了优化,同时封装了一个名为NodeMap的结构,便于代码复用。
Node:点结构

点击查看代码
public class Node implements Comparable<Node> {
    private final PersonPro personPro;		//	当前节点
    private final int distance;	//	与出发点的距离

    public Node(PersonPro personPro, int distance) {
        this.distance = distance;
        this.personPro = personPro;
    }

    public int getDistance() {
        return distance;
    }

    public PersonPro getPersonPro() {
        return personPro;
    }

    public int getPersonId() {
        return personPro.getId();
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Node)) {
            return false;
        }
        Node node = (Node) o;
        return distance == node.distance && personPro.equals(node.personPro);
    }

    @Override
    public int hashCode() {
        return Objects.hash(personPro.getId(), distance);
    }

    @Override
    public int compareTo(Node o) {
        return Integer.compare(distance, o.getDistance());
    }
}

Edge:边结构

点击查看代码
public class Edge implements Comparable<Edge> {
    private final PersonPro from;	//	出发节点
    private final PersonPro to;	//	终止节点
    private final int value;	//	边的权值

    public Edge(PersonPro from, PersonPro to, int value) {
        this.from = from;
        this.to = to;
        this.value = value;
    }

    public PersonPro getFrom() {
        return from;
    }

    public PersonPro getTo() {
        return to;
    }

    public int getValue() {
        return value;
    }

    @Override
    public int compareTo(Edge e) {
        return value - e.getValue();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Edge)) {
            return false;
        }
        Edge edge = (Edge) o;
        return value == edge.value && from.equals(edge.from) && to.equals(edge.to);
    }

    @Override
    public int hashCode() {
        return Objects.hash(from, to, value);
    }
}

NodeMap:封装以及堆优化的dijkstra

public class NodeMap {
    private static final int MAX_VALUE = 0x7fffffff;
    private final HashMap<PersonPro, HashSet<PersonPro>> personLinkedMap;
    private static final NodeMap NODE_MAP = new NodeMap();

    private NodeMap() {
        this.personLinkedMap = new HashMap<>();
    }

    public static NodeMap getNodeMap() {
        return NODE_MAP;
    }

    public void addPerson(PersonPro personPro) {
        personLinkedMap.put(personPro,new HashSet<>());
    }

    public void addRelation(PersonPro from, PersonPro to) {
        personLinkedMap.get(from).add(to);
    }

    public int dijkstra(PersonPro from, PersonPro to) {
        HashMap<PersonPro,Integer> ansMap = new HashMap<>();  //  ID -> DISTANCE
        HashSet<Integer> visitSet = new HashSet<>();
        for (PersonPro personPro: personLinkedMap.keySet()) {
            ansMap.put(personPro, MAX_VALUE);
        }
        ansMap.put(from, 0);
        PriorityQueue<Node> nodeQueue = new PriorityQueue<>();//	使用堆优化
        nodeQueue.add(new Node(from, 0));
        while (!nodeQueue.isEmpty()) {
            Node node = nodeQueue.poll();
            assert node != null;
            if (visitSet.contains(node.getPersonId())) {
                continue;
            }
            visitSet.add(node.getPersonId());
            for (Person person: node.getPersonPro().getLinkedPersons()) {
                PersonPro personPro = (PersonPro) person;
                if (!visitSet.contains(personPro.getId()) &&
                        ansMap.get(node.getPersonPro()) + personPro.queryValue(node.getPersonPro())
                                < ansMap.get(personPro)) {
                    ansMap.put(personPro,
                            ansMap.get(node.getPersonPro()) +
                                    personPro.queryValue(node.getPersonPro()));
                    nodeQueue.add(new Node(personPro,ansMap.get(personPro)));
                }
            }
        }
        return ansMap.get(to);
    }

}

三、Network扩展

类规格定义

Commodity:封装商品类

    /*@ public instance model non_null String name;	//	商品名称
      @ public instance model int price; //  商品价格
      @ public instance model int preference;  //  商品对应消费者偏好值
      @*/

Producer

   /*@ public instance model non_null Commodity;  //  生产的商品,一个生产者只生产一种商品
     @ public instance model int sales;  //  商品销售额
     @*/

Advertiser

  /*@ public instance model non_null Producer[] producers;  //  一个广告商可为多家厂商打广告
    @*/

Log:封装日志类

  /*@ public instance model non_null Customer customer;		// 消费者
    @ public instance model non_null Advertiser advertiser;	// 广告商
    @ public instance model non_null Producer producer;		// 生产者
    @*/

Customer

    /*@ public instance model non_null Log[] logs;  //  一个消费者的购买记录
      @ public instance model int preference;      //  消费者偏好值
      @*/
public class Advertisement extends MyMessage {

  
  /*@ public invariant socialValue == 0;
    @ public instance model non_null Advertiser advertiser;	 // 广告商
    @ public instance model non_null Producer producer;	         // 生产者
    @*/

    /*@ ensures type == 2;
      @ ensures group == null;
      @ ensures id == messageId;
      @ ensures person1 == messagePerson1;
      @ ensures person2 == messagePerson2;
      @*/
    public Advertisement (int messageId, Person messagePerson1, Person messagePerson2) {
        super(messageId, 0, messagePerson1, messagePerson2);
        this.advertiser = messagePerson1;
        this.producer = messagePerson2;
    }
...
}

方法规格定义

查询销售路径

/*@ public normal_behavior
  @ requires containsCustomer(id) && i < getCustomer(id).records.length;
  @ assignable \nothing;
  @ ensures \result == getCustomer(id).records[i];
  @ also
  @ public exceptional_behavior
  @ signals (PersonIdNotFoundException e) !containsCustomer(id);
  @ signals (RecordsiOutOfBoundException e) containsCustomer(id) &&
  @ 		i >= getCustomer(id).records.length;
  @*/
public /*@ pure @*/ Record getRecord(int id, int i);  //  查询id为`id`的消费者的第i条购买记录,从而获得销售路径

查询销售额

/*@ public normal_behavior
  @ requires containsProducer(id);
  @ assignable \nothing;
  @ ensures \result == getProducer(id).sales;
  @ also
  @ public exceptional_behavior
  @ signals (PersonIdNotFoundException e) !containsProducer(id);
  @*/
public /*@ pure @*/ int getSales(int id);

消费者购买

/*@ public normal_behavior
  @ requires containsCustomer(consumerId) && containsAdvertiser(advertiserId)
  @ 		&& containsProducer(producerId) 
  @ 		&& getAdvertiser(advertiserId).producers.contains(getProducer(producerId));
  @ assignable getProducer(producerId).sales, getCustomer(consumerId).logs;
  @ ensures \old(getProducer(producerId).sales) + getProducer(producerId).price == getProducer(producerId).sales;
  @ ensures \old(getCustomer(consumerId).logs.length) + 1 == getCustomer(consumerId).logs.length;
  @ ensures getCustomer(consumerId).logs[getCustomer(consumerId).logs.length - 1].customer 
  @ 		== getCustomer(consumerId);
  @ ensures getCustomer(consumerId).logs[getCustomer(consumerId).logs.length - 1].advertiser 
  @			== getAdvertiser(advertiserId);
  @ ensures getCustomer(consumerId).logs[getCustomer(consumerId).logs.length - 1].producer 
  @ 		== getProducer(producerId);
  @ ensures (\forall int i; 0 <= i && i < \old(getCustomer(consumerId).logs.length);
  @ 		getCustomer(consumerId).logs[i] == \old(getCustomer(consumerId).logs[i]));
  @ also
  @ public exceptional_behavior
  @ signals (PersonIdNotFoundException e) !containsCustomer(consumerId);
  @ signals (PersonIdNotFoundException e) containsCustomer(consumerId) && !containsAdvertiser(advertiserId);
  @ signals (PersonIdNotFoundException e) containsCustomer(consumerId) && containsAdvertiser(advertiserId)
  @ 		&& !containsProducer(producerId);
  @ signals (NotAdvertiseException e) containsCustomer(consumerId) && containsAdvertiser(advertiserId)
  @ 		&& containsProducer(producerId) 
  @ 		&& !getAdvertiser(advertiserId).producers.contains(getProducer(producerId));
  @*/
public void purchase(int consumerId, int advertiserId, int producerId);

四、程序对拍

  • 本次对拍我并未参与测试数据生成的工作,由另一位小伙伴完成,在此便不重点讨论数据生成部分。

  • 传统对拍即获取输入输出,然后到网址在线比较两份程序输出的异同;虽然但是,这种低效率工作不能符合我的要求(指bug频出,对拍数据太少会寄),于是我通过数十行Python代码,实现了如下简易多人并发对拍机。

  • 对拍任务毫无疑问是CPU密集型任务,使用多线程或者协程优化都没有太大意义,通过OS的学习,我认为使用多进程 + 进程池的方式实现对拍,可以让对拍效率最大化,对拍机源码如下:

工具函数部分

import os
import subprocess
import multiprocessing
import time

#	通过管道通信输入方式运行jar包程序,同时返回运行结果,异常,运行时间等数据
def run_jar(jar_path, test_stdin: str):
    begin = time.time()
    runner = subprocess.Popen('java -jar %s' % jar_path,
                              shell=True,
                              stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = runner.communicate(test_stdin.encode('utf-8'))
    end = time.time()
    result = str(stdout, 'utf-8').split('\n')
    cost = end - begin
    return jar_path, result[:len(result) - 1], str(stderr, 'utf-8'), cost

#	解析txt输入数据,转为字符串输入
def get_stdin_from_file(file_path: str):
    with open(file_path, "r") as stdin_file:
        stdin = stdin_file.read()
        return stdin, stdin.split('\n')[:-1]

#	比较两个程序输出异同
def get_difference(arr1: list[str], arr2: list[str]):
    for i in range(len(arr1)):
        if arr1[i] != arr2[i]:
            return i
    return -1

对拍函数主体

#	对拍程序
def compare(jar_list: list[str], stdin_dir_path: str):
    stdin_list = filter(lambda x: x.endswith(".txt"), os.listdir(stdin_dir_path))
    index = 0
    for stdin in stdin_list:
        result_list = []
        stdin_str, stdin_map = get_stdin_from_file("%s/%s" % (stdin_dir_path, stdin))
        pool = multiprocessing.Pool(processes=3)
        for item in jar_list:
            result_list.append(pool.apply_async(run_jar, (item, stdin_str)))
        pool.close()
        pool.join()
        print("testcase %d begin" % index)
        stdout_list = []
        cost_list = []
        jar_list = []
        for res in result_list:
            jar_path, stdout, stderr, cost = res.get()
            if stderr != '':
                print("error occurs with %s at testcase %s\n error as:\n %s " % (jar_path, stdin, stderr))
                return
            stdout_list.append(stdout)
            cost_list.append(cost)
            jar_list.append(jar_path)
        for i in range(0, len(stdout_list) - 1):
            if stdout_list[i] != stdout_list[i + 1]:
                print("difference occurs at testcase %s" % stdin)
                difference_line = get_difference(stdout_list[i], stdout_list[i + 1])
                print("difference at line %d, instruction is %s " %
                      (difference_line, stdin_map[difference_line]))
                for j in range(len(jar_list)):
                    print("%s's output is %s" % (jar_list[j], stdout_list[j][difference_line]))
                return
        for i in range(len(cost_list)):
            print("%s:\n use time: %.6fs" % (jar_list[i], cost_list[i]))
        print("testcase %d end" % index)
        index += 1

main函数部分

if __name__ == '__main__':
    compare(["F:/OO/tasks/homework11/zjw.jar",
             "F:/OO/tasks/homework11/Archer.jar",
             "F:/OO/tasks/homework11/Lancer.jar",
             "F:/OO/tasks/homework11/Assassin.jar",
             "F:/OO/tasks/homework11/Berserker.jar",
             "F:/OO/tasks/homework11/Saber.jar"] #	待对拍的所有jar路径存放于此列表下
            , "F:/OO/tasks/homework11/normal0group" # 测试数据所在文件夹路径)

五、心得体会

  • JML规格,或者说一般意义上的规格,是为了软件开发和团队合作而生的。其在团队合作的作用应该是减少不同人代码开发的耦合度,同时划分明确的分工。在软件开发的层面上,更多地侧重于把软件功能层次化,细分化。
  • 规格,侧重于功能而非实现,也就是先给出了一个具体地框架,我认为可以理解为给出了一个黑箱,规格一开始要说明这个黑箱能干什么;然后,规格会进一步拆解这个黑箱,去说明更多的小黑箱能干什么;最终规格会缩小到方法地层面上,说明一个方法黑箱应具有怎么样地输入和输出。
  • 对于规格地实现,我的理解是,只需要理解规格给出的功能性说明,而不需要在意规格给出的可能的代码逻辑。举个最简单的例子,第一次作业中Path的数据规格给了一个int[]的类型,实际上这只是说明存在这样的数据结构,而非一定要实现这样的数组,实际上,真正这么搞的同学估计没有几个。
  • 我觉得规格使用相当有必要,而且在完成规格后再实现代码会具有更高的效率。目前为止,我所接触的代码都是很小的,几乎不成功能,但是我觉得如果我想完成一个软件,那么我不仅会写设计文档,还会在完成设计文档后,写详细的规格说明书,最后再开始实现代码。
  • 最后,规格的限制固然必要,但是如何让规格描述更加通俗易懂且严谨,却也是我们需要思考的问题。
posted @ 2022-06-04 12:10  不怕事学渣扛把子势力  阅读(20)  评论(2编辑  收藏  举报