2020年春季学期
计算机学院《软件构造》课程
Lab 2实验报告
学号 1180300223
班号 1803002
目录
1 实验目标概述 1
2 实验环境配置 1
3 实验过程 1
3.1 Poetic Walks 2
3.1.1 Get the code and prepare Git repository 2
3.1.2 Problem 1: Test Graph 2
3.1.3 Problem 2: Implement Graph 3
3.1.3.1 Implement ConcreteEdgesGraph 3
3.1.3.2 Implement ConcreteVerticesGraph 6
3.1.4 Problem 3: Implement generic Graph 7
3.1.4.1 Make the implementations generic 7
3.1.4.2 Implement Graph.empty() 7
3.1.5 Problem 4: Poetic walks 8
3.1.5.1 Test GraphPoet 8
3.1.5.2 Implement GraphPoet 8
3.1.5.3 Graph poetry slam 9
3.1.6 Before you’re done 9
3.2 Re-implement the Social Network in Lab1 10
3.2.1 FriendshipGraph类 10
3.2.2 Person类 10
3.2.3 客户端main() 10
3.2.4 测试用例 11
3.2.5 提交至Git仓库 11
3.3 Playing Chess 12
3.3.1 ADT设计/实现方案 12
3.3.2 主程序MyChessAndGoGame设计/实现方案 21
3.3.3 ADT和主程序的测试方案 29
4 实验进度记录 32
5 实验过程中遇到的困难与解决途径 34
6 实验过程中收获的经验、教训、感想 34
6.1 实验过程中收获的经验和教训 34
6.2 针对以下方面的感受 34
1 实验目标概述
本次实验训练抽象数据类型(ADT)的设计、规约、测试,并使用面向对象 编程(OOP)技术实现 ADT。具体来说:
⚫ 针对给定的应用问题,从问题描述中识别所需的 ADT;
⚫ 设计 ADT 规约(pre-condition、post-condition)并评估规约的质量;
⚫ 根据 ADT 的规约设计测试用例;
⚫ ADT 的泛型化;
⚫ 根据规约设计 ADT 的多种不同的实现;针对每种实现,设计其表示 (representation)、表示不变性(rep invariant)、抽象过程(abstraction function)
⚫ 使用 OOP 实现 ADT,并判定表示不变性是否违反、各实现是否存在表 示泄露(rep exposure);
⚫ 测试 ADT 的实现并评估测试的覆盖度;
⚫ 使用 ADT 及其实现,为应用问题开发程序;
⚫ 在测试代码中,能够写出 testing strategy 并据此设计测试用例。
2 实验环境配置
IDE:Eclipse
JDK:1.8
3 实验过程
请仔细对照实验手册,针对三个问题中的每一项任务,在下面各节中记录你的实验过程、阐述你的设计思路和问题求解思路,可辅之以示意图或关键源代码加以说明(但千万不要把你的源代码全部粘贴过来!)。
3.1 Poetic Walks
该任务需要实现一个带有标记顶点的可变加权有向图的抽象数据类型, 之后的写诗为找路的过程。
实现该抽象数据类型, 题目给了两种实现方法:
获取代码: 使用git-clone指令
建立本地仓库: 使用Eclipse自带的Team -> share project
管理本地开发: 使用Eclipse自带的Team -> Commit
3.1.2 Problem 1: Test Graph
以下各部分,请按照MIT页面上相应部分的要求,逐项列出你的设计和实现思路/过程/结果。
该部分就是将Graph.java接口中的empty函数修改为
但需要注意的是, 如果想通过GraphStaticTest的话, 需要将ConcreteEdgesGraph.java中的vertices函数修改为
3.1.3 Problem 2: Implement Graph
以下各部分,请按照MIT页面上相应部分的要求,逐项列出你的设计和实现思路/过程/结果。
3.1.3.1 Implement ConcreteEdgesGraph
想要实现该类的功能, 首先需要关注的是Edge类, 因为在ConcreteEdgesGraph类中使用了Edge类.
我们知道, 想要构成一条有向边, 我们需要有边的起点(源点), 边的终点, 以及边的权值. 所以在设计Edge类的时候, 我们需要这三个参数来构造一条边, 所以Edge类的参数就为
对于Edge来说, source和target都不能是null, weight必须是非负数, 所以checkRep()为
同时对于每一个类来说, 都需要有Getter和Setter函数, 这个不必多做解释
需要介绍的是,我在Edge类中单独写了一个判断两条边是否相同的函数, 思路是如果两条边的source(起点)和target(终点)相同, 那么两条边就是相同的
Edge类到此结束, 接下来看ConcreteEdgeGraph类
ConcreteEdgeGraph类中题目给定的几个数据类型有点的集合vertices, 存储数据类型为Edge类的动态数组edges, 以及一个Edge类数据edge
由于没有参数, 所以构造器也不需要任何输入
对于ConcreteEdgeGraph类来说, edge是不能重复的, 所以checkRep()应当检测edges里是否有重复元素
由于ConcreteEdgeGraph类需要实现接口Graph中的函数, 忽略empty函数,接下来便一一介绍并实现Graph接口中的六个函数:
最后再讲一讲toString(), 该函数主要是为了防止表示泄露, 所以必须写, 至于怎么写比较自由, 能表现出该类的特征最好(Edge类中也要写, 但是我觉得放在这里说比较好)
这里提供我的想法
测试的话不用多说, 在上一次实验中已经很好地体验过测试, 仅提供测试通过的图片以及覆盖度
3.1.3.2 Implement ConcreteVerticesGraph
该类与Edge类大同小异, 主要说一说Vertex类
如果想要用vertex来实现图, 那么需要Java中的Map类型来存放与该点连接的点以及该边对应的权值, 所以需要的参数不仅有点的标签, 还需要有以该点为终点的边的起点及边的权值映射sources_map, 以及以该点为起点的边的终点及边的权值映射targets_map
同时为了防止泄露已经数据被篡改, 不能直接get到sources_map和targets_map, 对这两个map的操作必须在Vertex类内部实现, 这是我在听第二次试验辅导课时的老师讲的, 对我来说也算是一个帮助, 所以记录在这里
测试与ConcreteEdgesGraph相同, 仅提供测试通过的图片以及覆盖度
其余的没什么好说的, 与ConcreteEdgesGraph.java大同小异, 略去
3.1.4 Problem 3: Implement generic Graph
3.1.4.1 Make the implementations generic
只需要将原本是String的地方改为L以及加即可
需要注意的是, 我在修改之后, Edge类变成了static类型, 但也符合题目要求
3.1.4.2 Implement Graph.empty()
需要注意的不多, 之前说先不讨论的empty()函数在此才使用, 我选择ConcreteEdgeGraph类来实现
但是题目要求不能出现警告以及不能使用@SuppressWarnings注释, 在此附图
可以看到全部没有警告, 代码中也没有@SuppressWarnings注释
由于之前已经有String类型的测试了, 这次我选择了Integer类型, 同样只提供测试通过的图片以及覆盖度(覆盖图相对较低的原因是没有测试Edge类)
3.1.5 Problem 4: Poetic walks
3.1.5.1 Test GraphPoet
构建文本, 输入一串文字, 如果两个词之间存在一个词, 就会添加到其中,若存在多个, 选择权值最高的. 但需要注意的是, 若两个词之间没有这样的路径(即中间词的sources(起点)不包含第一个词或中间词的targets(终点)不包含第二个词)的话, 则不添加词
3.1.5.2 Implement GraphPoet
关于文件读入, 我在lab1里了解的十分详细了, 再贴一下我的那个博客地址
https://blog.csdn.net/weixin_43348617/article/details/104710633
主要讲一讲poem(String input)函数, 该函数是整个GraphPoet的核心, 但是我在参考往届学长的博客时, 发现有许多学长也没有很好地理解这个函数的作用. 像3.1.5.1说的, 需要注意的是, 若两个词之间没有这样的路径(即中间词的sources(起点)不包含第一个词或中间词的targets(终点)不包含第二个词)的话, 则不添加词, 这个是十分重要的判断标准, 是我询问一个同学时他给我的信息, 因为这是曾经自己犯的错, 所以在此需要提出, 其他的话其实难度不大,也就不需要贴代码了
3.1.5.3 Graph poetry slam
我认为与其在Main里更新, 不如直接在测试中测试好了, 我节选了两句来自莎士比亚的sonnet18中的诗句, 测试通过, 这里也仅提供测试通过的图片以及覆盖度
3.1.6 Before you’re done
请按照http://web.mit.edu/6.031/www/sp17/psets/ps2/#before_youre_done的说明,检查你的程序。
如何通过Git提交当前版本到GitHub上你的Lab2仓库。
git-push
在这里给出你的项目的目录结构树状示意图。
3.2 Re-implement the Social Network in Lab1
这个任务利用前面所写的ConcreteEdgeGraph类或者ConcreteVerticesGraph类来重新实现lab1中的FriendshipGraph, 由于同样是用图这个数据结构来实现, 想要复用P1中的ADT来实现人际关系图. 但需要注意的是, 人际关系图实际上是一个顶点带标签(人名), 无向无权值图这样的数据类型, 所以在复用P1中的代码时, 加边的时候需要双向加边以及将权值全部设为相同的固定值(例如1等)
3.2.1 FriendshipGraph类
对于FriendshipGraph类的实现, 我选择ConcreEdgesGraph类来实现(相比点图来说, 边图更加简单直观), 所以需要尽量复用ConcreEdgesGraph类中的方法
接下来针对lab1中的FriendshipGraph类的三个函数进行重写:
3.2.4 测试用例
与lab1中的测试完全相同, 将lab1中的测试import过来就行, 仅仅提供测试通过的图片以及覆盖度
这里覆盖率较低的原因是FriendshipGraph类中有main()函数未能测试
3.2.5 提交至Git仓库
如何通过Git提交当前版本到GitHub上你的Lab3仓库。
git-push
在这里给出你的项目的目录结构树状示意图。
3.3 Playing Chess
关于P3我单独写了一篇怎么讲抽象与现实联系的文章,在此先贴一下
https://blog.csdn.net/weixin_43348617/article/details/105528386
我对于P3的设计架构为:
每一级都是严格把控, 只有严格的上一级才能调控下一级, 不能越级处理,不能下级调控上级, 不能调用无关级. 也就是说只有Piece类能够直接调用Position类, 而越级的Player无法直接调用Position类; 只有Game类才能调用Board类, 而Player类无法调用和更改Board类. 这样的设计既保证了程序的严谨性和设计合理性, 又保证了程序的安全性, 由于每一级都是严格控制和绝对挂钩, 想要越级或者调用无关类的话会造成程序崩溃, 接下来一一介绍各个ADT
3.3.1 ADT设计/实现方案
设计了哪些ADT(接口、类),各自的rep和实现,各自的mutability/ immutability说明、AF、RI、safety from rep exposure。
必要时请使用UML class diagram(请自学)描述你设计的各ADT间的关系。
Position类
Position类是所有类中等级最低的, 也就是最底层的东西, 需要先介绍. 对于一个position(位置)来说, 需要有其横坐标与纵坐标, 所以fields参数应为x和y
同时Position类作为位置是可以出现负数的情况(在Piece类中会细讲), 而两种游戏的棋子坐标都是相同的类型, 不需要为其中某一个类型写特殊的方法. 同时x和y为private类型, 没有checkRep()
至于Getter和Setter不多作介绍, 只需要注意Position是可以更改坐标值即可
Piece类
(1) Piece接口
对于两种棋子来说, 有很多操作都是两者兼具的, 例如Getter和Setter, 以及设置棋子状态和移除棋子, Piece.java接口为两种棋子提供了共有的操作, 而两种棋子不同的方法会在两种棋子的类里介绍
(2) GoPiece类
作为围棋棋子来说, 必须要有颜色和坐标, 为了方便得到棋子的状态, 我还设置了一个state作为棋子的状态
这里主要讲一讲关于state. state是用于判断棋子状态的, 如果state = 1, 说明此时该棋子已经被移除, 所以其坐标也设置为(-1, -1)[在Position类中曾提到过], 如果state为0, 说明该棋子已经存在但是并没有使用(基本不存在这种情况, 具体原因在Player类中会说明, 但是仍需列出), 如果state为1, 说明该棋子存在并且已经被使用
所以checkRep()也很简单, 围棋中所有方法都是Graph接口中的方法, 唯一需要介绍的是removePiece()函数, 它将坐标设为(-1, -1), state设为-1, 就可以代表该棋子已被移除
(3)ChessPiece类
作为国际象棋棋子来说, 除了和围棋一样必须有颜色和坐标之外, 还需要一个棋子的名称, 我将其设为label, 所以参数有4个
除了label和state之外和GoPiece类基本完全相同, 所以不再介绍重复内容
先说label, label是国际象棋不同于围棋的东西, 围棋只有黑白, 但是国际象棋还有棋子名称, 分别是King, Queen, Bishop, Knight, Rook和Pawn六种棋子类型以及棋子的左右, 我觉得如果还要再设置棋子的左右之分太过于繁琐, 所以label不仅要包含棋子名称, 还需要包含棋子的左右, 这部分会在Game类里介绍, 这里就不展开讨论了
其次是state. 由于国际象棋一开始棋盘上就应该摆好棋子, 所以不存在围棋中的棋子存在但还未摆放的情况, 所以state只能为1或者-1, 至于其代表的含义与围棋中的state代表的1和-1相同, 重复内容不再介绍
在该类中只有一个特别的方法, 就是label的get方法, 很简单, 不多作介绍
(2)ChessAction类
国际象棋有两种操作, 一种是将自己仍在棋盘上的棋子移动到一个空的位置, 一种是移动自己的棋子吃掉对方的棋子.
对于ChessAction来说, 没有任何的表示不变量, 也没有任何参数, 仅仅是为了给Player提供操作方法
①移子
移子是将自己仍在棋盘上的棋子移动到一个空的位置. 但对于ChessAction类来说, 只能调用Piece类, 所以此处的移动也只是将一个棋子的位置改变为目标位置
②吃子
吃子是移动自己的棋子吃掉对方的棋子. 但对于ChessAction类来说, 只能调用Piece类, 所以此处的吃子是目标位置的对方棋子remove, 再将自己的棋子移动到目标位置
(3) ChessBoard类
国际象棋棋盘是一个8×8的棋盘, 由于棋子是放在格子里而非交线上, 所以最左下角的棋子坐标应为(1, 1), 最右上角的棋子坐标应为(8, 8), 棋盘上的棋子存储与GoBoard类相同
除开棋盘上获得棋子信息和特有的移动棋子操作之外, 我还新加了一个为了初始化而向棋盘上添加棋子的操作, 为了让Player类中的Piece对象和Board中的Piece对象相同, 所以初始化棋盘我放在了Game类中, 所以此处需要这个函数为Game初始化棋盘提供方法
(3) ChessGame类
国际象棋需要两名玩家, 以及玩家的操作, fields设计与GoGame基本相同
国际象棋中白方先行动, 白方后行动, 所以将player1设定为白子, 将player2设定为黑子, 由于国际象棋需要初始化棋盘和玩家剩余棋子, 所以在这里需要创建Piece类, 但是没有直接使用Piece类中的方法, 符合上面的规划图
ChessGame必须实现最终客户端的所有要求, 着重介绍初始化游戏, 移动棋子和吃子两个方法
① 初始化游戏
前文提到过, 需要初始化游戏, 最直观的就是要初始化棋盘和玩家剩余棋子, 所以我在初始化中新建了很多Piece对象, 但没有调用其中的方法
② 移动棋子
移动棋子有两个方面, 一个是更新玩家剩余棋子中所移动的那个棋子的坐标, 还有一个直观的就是更改棋子在棋盘上的位置. 所以对于棋盘来说, 需要删除原来位置上的那个棋子, 并在移动的目标位置上添加那个已经更新过的棋子
③ 吃子
吃子和移动棋子远离基本上相似, 不同的就是移动必须移动到空的位置上, 而吃子是移动到有对方棋子的位置上, 所以只需要在移动前将对方的棋子从player中删除并在棋盘上删除即可
3.3.2 主程序MyChessAndGoGame设计/实现方案
辅之以执行过程的截图,介绍主程序的设计和实现方案,特别是如何将用户在命令行输入的指令映射到各ADT的具体方法的执行。
在主程序中, 我还设计了一个用来清屏的函数, 防止指令什么的看不清楚, 原理很简单
首先我会让用户选择玩围棋游戏或者国际象棋游戏
(1) 围棋游戏
选择1之后, 我会要求用户输入两个玩家的名字, 并提示用户各自的棋子颜色和行动顺序
之后便会给用户一个menu, 用户可以选择数字进行操作
在玩家选择1, 也就是put piece的时候, 会提示用户输入信息
用户输入想要放置的棋子位置, 就会执行Game中的put操作并打印棋盘
由于用户执行的是前三项: put, remove或者skip, 所以会轮到第二名玩家进行选择, 如果玩家选择了4或者5就会返回信息后再次要求玩家选择
第二名玩家执行put, 又回到第一名玩家
此时第一名玩家选择5, 查询自己的棋子数量, 同样会返回查询信息后要求用户再次选择
第一名玩家选择2, 提走第二名玩家的棋子
此时第二名玩家选择3, 跳过ta的回合
此时第一名玩家选择6, 结束游戏, 玩家可以查看自己的行动历史
选择1查看第一名玩家的行动历史
选择2查看第二名玩家的行动历史
选择3结束观看历史, 关闭游戏, 此时进程结束, 右上方红框变灰
(2)国际象棋游戏
选择2之后, 用户也和上面围棋游戏一样要输入玩家名字, 并提示玩家的棋子颜色和行动顺序
第一个玩家选择1后, 会提示用户想要移动哪个坐标上的棋子
输入后会提示用户想要移动到哪个位置
输入想要移动的位置之后, 会打印棋盘, 可以看到原本在(4, 1)上的白色方的King移动到了(5, 6)这个位置
同样的, 由于用户执行的是前三项: put, remove或者skip, 所以会轮到第二名玩家进行选择, 如果玩家选择了4或者5就会返回信息后再次要求玩家选择
此时输入2, 会提示用户想用哪个位置的棋子来进行吃子操作
输入需要移动的棋子坐标之后, 会提示用户想要吃掉哪个位置的对方棋子
输入对方棋子位置之后会打印棋盘, 可以看到原来(5, 7)位置上的black Pawn4移动到了原来的white King的位置, 而white King从棋盘上消失了
此时输入5查询第一个玩家的棋子数, 可以看到从16个旗子变成15个了
为了测试3的功能, 这次第一名玩家选3, 跳过该轮, 可以看到本轮跳过, 轮到第二名玩家
第二名玩家选择6, 游戏结束, 现在可以查看双方的行动历史
选择1查看第一名玩家的历史
选择2查看第二名玩家的历史
选择3结束观看历史, 关闭游戏, 此时进程结束, 右上方红框变灰
3.3.3 ADT和主程序的测试方案
介绍针对各ADT的各方法的测试方案和testing strategy。
介绍你如何对该应用进行测试用例的设计,以及具体的测试过程。
我的测试策略很简单, 就是将上一级需要调用的方法以及较为复杂的方法进行测试, 防止测试过于臃肿, 所以部分测试覆盖率较低, 但我认为其实并没有什么影响, 毕竟一些很简单的Getter之类的写测试除了浪费时间之外没有任何作用
附上每一个测试的通过图以及覆盖图
4 实验进度记录
请使用表格方式记录你的进度情况,以超过半小时的连续编程时间为一行。
每次结束编程时,请向该表格中增加一行。不要事后胡乱填写。
不要嫌烦,该表格可帮助你汇总你在每个任务上付出的时间和精力,发现自己不擅长的任务,后续有意识的弥补。
日期 时间段 计划任务 实际完成情况
3.18 14:00-17:30 编写ConcreteEdgeGraph.java 完成
3.19 10:00-12:30 编写ConcreteVerticesGraph.java 遇到困难
未能完成
3.19 19:00-22:00 编写ConcreteVerticesGraph.java 完成大部分代码
但仍有bug
3.21 10:00-11:30 编写ConcreteVerticesGraph.java 仍有bug
3.21 19:00-21:00 完成ConcreteVerticesGraph.java 完成
3.22 14:00-16:30 完成Graph的test 完成
3.25 14:00-17:00 编写GraphPoet.java 有少部分未能完成
3.27 14:00-17:00 完成GraphPoet.java及其test 遇到困难
未能完成
3.28 14:00-16:00 完成GraphPoet.java及其test 完成
4.1 14:00-17:00 完成Friendship.java及Person.java getDistance有bug
4.1 19:00-20:30 修复bug并完成test 完成
4.2 10:00-11:30 完成部分P3设计规划 完成
4.2 14:00-16:30 完成P3设计规划 未能完成
4.2 19:40-21:50 完成P3设计规划 完成
4.3 18:40-22:00 完成规划中的Position类
Piece类和Action类 完成
4.4 10:00-11:30 完成规划中的Board类,Player类 遇到困难
未能完成
4.4 14:00-16:30 完成规划中的Board类,Player类 完成Board类
Player类遇到困难
4.8 14:00-17:00 对之前写的类进行测试验错 完成
4.8 19:00-20:30 完成规划中的Player类 完成
4.9 10:00-11:30 完成规划中的Game类
中的GoGame类 完成, 但有错误
4.9 19:00-21:30 重新思考Game类, 对Game类进行重新规划 完成
4.10 16:00-17:30 完成规划中的Game类 完成
4.10 19:00-22:30 完成主程序, 完成整个P3 完成
4.11 10:00-17:40 编写实验报告 完成
5 实验过程中遇到的困难与解决途径
遇到的难点 解决途径
不知道怎么基于点来存储图中的信息 通过同学的指导以及在QQ和Piazza上询问老师得到解答
对GraphPoet.java的作用理解错误 在和同学闲聊时聊到这个, 更正了错误
完成P3的设计规划有困难 在网上参考了往届学长的想法, 再结合自己的想法完成了规划
P3一层一层往上写的时候出现了难以解决的bug 对之前写的类进行test验错,成功找出bug并修正
P3的Game类设计思想出了问题 不断地思考和研究自己的规划图
6 实验过程中收获的经验、教训、感想
6.1 实验过程中收获的经验和教训
6.1.1 `针对以下方面的感受
(1) 面向ADT的编程和直接面向应用场景编程,你体会到二者有何差异?
(2) 使用泛型和不使用泛型的编程,对你来说有何差异?
(3) 在给出ADT的规约后就开始编写测试用例,优势是什么?你是否能够适应这种测试方式?
(4) P1设计的ADT在多个应用场景下使用,这种复用带来什么好处?
(5) P3要求你从0开始设计ADT并使用它们完成一个具体应用,你是否已适应从具体应用场景到ADT的“抽象映射”?相比起P1给出了ADT非常明确的rep和方法、ADT之间的逻辑关系,P3要求你自主设计这些内容,你的感受如何?
(6) 为ADT撰写specification, invariants, RI, AF,时刻注意ADT是否有rep exposure,这些工作的意义是什么?你是否愿意在以后编程中坚持这么做?
(7) 关于本实验的工作量、难度、deadline。
(8) 《软件构造》课程进展到目前,你对该课程有何体会和建议?
(1) 我认为面向ADT编程是一项很好的技术, 不再面向过程, 而是对一个对象进行编写, 这有很好的封装性, 而且还有继承之类的. 这种面向对象编程能够报纸外部接口不变的情况下改变内部实现, 从而减少甚至避免了外界的干扰. 以对象为核心进行编程, 能够更好更清晰地实现目标
(2) 使用泛型能够改变输入数据的类别, 从而实现不同种类的数据能复用同一个方法, 使用泛型编程能够提高代码效率
(3) 给出规约后开始写测试, 能够及时得到来自代码的反馈, 很容易就能知道自己所写代码的正确性, 我认为这是一个很好的方案
(4) 代码的复用能够减少工程量, 提升代码效率和工作效率, 在大部分场景下, 能够复用代码都能极大地减少工作量, 是一个十分完美的方案
(5) 从0开始写让我在开发的时候基本没有遇到难以解决, 必须寻求他人帮助才能解决的, P1中有很多东西是我无法理解的, 只能通过寻求他人帮助才能理解, 而P3完全由我设计, 自由度很高, 在写之前做了一个规划图, 写起来也比较轻松有趣
(6) 为了防止数据泄露以及出现难以调试的bug, 这是十分有意义的. 在今后的编程中我也会贯彻这个思想
(7) 本次实验难度相较于上次提升了不少, 但是总的来说比上次实验有趣了很多, 工作量虽然很大, 但是乐在其中, ddl给的比较宽松, 我中间有时候遇到困难会一两天不来弄, 但还是能在截至前一天全部弄完, 感觉还是很开心
(8) 对于这门课, 我认为再一次让我接触到了编程, 感受到了编程的魅力, 它给的反馈感及时而强烈, 我还记得完成P3时候的开心和满足, 但是我觉得习题课应该提早一点讲, 毕竟在实验都快要完成的时候讲习题课, 我认为是事倍功半的效果