文章目录
文本查询再探
这一次我们扩展最初的文本查询程序,使之支持逻辑查询(与或非查询)和逻辑查询的组合查询。
程序的输出示例如下:
注:本程序在本人上个帖子中的简单文本查询的基础上实现。TextQuery和QueryResult类的相关代码见:
https://blog.youkuaiyun.com/coreyckw/article/details/147825552?spm=1011.2124.3001.6209
实现方案
我们似乎可以用TextQuery来表示单词查询,把此类作为基类,然后派生出其他查询。但这么做会遇到很多麻烦的问题:逻辑非查询很难表示为单词查询,因为我们很难获得某个文本中某个单词的“逻辑非单词”。此外逻辑与和逻辑或查询包含两个查询结果的合并操作。
我们不妨对上述4中查询进行进一步抽象,定义4个类分别表示上述4种查询
- Word Query —单词查询
- NotQuery—逻辑非查询
- OrQuery—逻辑或查询
- AndQuery—逻辑与查询
这些类只包含两个操作eval和rep
- eval,接受一个TextQuery对象,并返回一个QueryResult对象。eval函数使用rep创建一个表示查询结果的QueryResult。
- rep,返回基础查询的string表示形式(为了支持逻辑查询和组合查询,程序的查询表达式不再是string的形式,我们通过重载位运算符来实现查询。我们用rep函数返回查询的string表示形式以便和用户进行交互【实际上用户的查询用代码表示,而查询结果的输出种,输出查询的string表示形式】)。输出运算符使用rep打印查询表达式。
程序设计
继承体系
上述4种查询类并不具备彼此继承的关系,我们可以为这些类设计一个统一的抽象基类QueryBase。在QueryBase种把eval和rep定义纯虚函数。此外OrQuery和AndQuery具备一种特殊的属性,他们各自包含两个运算对象。因此我们可以如下设计继承体系。
对外接口:
我们不希望对用户提供统一的接口来隐藏我们的继承体系,以便提供更加良好的用户体验。因此我们设计一个用户接口类Query。用户可以使用如下的代码表示查询
Query q= (Query("abc") & Query("asdf")) | Query("fads")
==Query种保存一个QueryBase指针,该指针绑定到QueryBase的派生类对象上。==Query类与QueryBase提供相同的操作,eval和rep。同时Query会定义重载运算符来表示查询操作。
通过对上述用户查询代码分析我们可以确定,Query有一个接受string类型的构造函数。同时Query类重载了与或非运算符。Query类根据用户的查询表达式动态的分配一个新的QueryBase派生类的对象。下面我们介绍重载运算符和构造函数
- &运算符生成一个绑定到AndQuery对象上的Query对象;
- |运算符生成一个绑定到OrQuery对象上的QUery对象;
- ~运算符生成一个绑定到NotQuery对象上的Query对象
- 接受string参数的Query构造函数生成一个WordQuery对象。
梳理类的工作机理
在这个应用程序中,很大一部分工作是构建查询对象。为了深入解析类的创建,我们以Query q= (Query("abc") & Query("asdf")) | Query("fads")
为例,给出这条查询表达式创建对象的过程。
如下图所示,首先构造一个与查询,该与查询分为一个单词查询和一个And查询。单词查询构建了WordQuery对象,And查询分别构建了两个WordQuery对象。因此Query的q首先指向一个OrQuery对象(该对象由|运算符负责创建),接着该OrQuery对象指向它的两个运算对象:AndQuery对象和WordQuery对象(这里的AndQuery对象是由&运算符创建的,WordQuery对象是由Query(“fads”)构造函数创建的)。AndQuery对象指向它的两个运算对象,这两个WordQuery对象分别是由Query(“abc”) 和Query(“asdf”)的构造函数创建的。
当对象创建完成后,对一条查询语句求值的过程就沿着箭头方向依次对每个对象求值。例如我们对q调用eval函数,则该调用语句将令q所指的OrQuery对象eval它自己。对该OrQuery求值实际上是对它的两个运算对象执行eval操作:一个运算对象是AndQuery,另一个是查找单词wind的WordQuery。接下来,对AndQuery求值转化为对它的两个WordQuery求值,分别生成单词fiery和bird的查询结果。
编码实现
QueryBase和Query类
Query类中存放大的是QueryBase指针(实现时采用shared_ptr指针),该指针指向QueryBase类的派生类。因此我们把Query类生声明为QueryBase类的友元。
//QueryBase.h
#pragma once
//QueryBase.h
//#ifndef QUERY_BASE_H
//#define QUERY_BASE_H
#include"TextQuery.h"
class QueryBase {
friend class Query;
protected:
using line_no = TextQuery::line_no;
virtual ~QueryBase() = default;//我们希望派生类对象各自负责销毁自己定义虚析构函数
private:
//eval函数返回与当前Query匹配的QueryResult,基础还是单词查询,因此形参是TextQuery
virtual QueryResult eval(const TextQuery&) const = 0;
virtual std::string rep() const = 0;
};
//#endif
接受一个string参数的Query构造函数将创建一个新的WordQuery对象,然后将它的shared_prt成员绑定到这个新创建的对象上。&、|和~运算符分别创建AndQuery、OrQuery和NotQuery对象,这些运算符将返回一个绑定到新创建的对象上的Query对象。
为了支持这些运算符,Query还需要另外一个构造函数,它接受指向Query_base的shared_ptr并且存储
给定的指针。我们将这个构造函数声明为私有的,原因是我们不希望一般的用户代码能随便定义Query_base对象。此外我们需要将这些运算符定义为友元以便他们可以访问Query类的私有构造函数。
我们可以用这个例子来辅助理解,如果我们创建一个普通查询,那么直接调用Query的形参string构造函数即可。如果创建一个And查询,程序会首先构造一个AndQuery对象,然后根Query的shared_ptr成员会绑定在这个AndQuery对象上。AndQuery对象的创建就是&运算符调用Query私有构造函数拆创建的。我们不希望用户直接创建具体的查询对象,因此我们 把该构造函数定义为私有。接着AndQuery对象的构造函数又会创建两个Query对象,这两个Query对象调用形参string构造函数。
我们在Query类中声明了一个输出运算符的重载,因为在逻辑上属于Query类的辅助函数,因此我们把声明写在Query类的头文件中。输出运算符中我们调用Query类的成员函数,并不访问Query类的对象因此不需要声明为友元。
//Query.h
#ifndef QUERY_H
#define QUERY_H
#pragma once
#include "QueryBase.h"
#include"WordQuery.h"
class Query {
friend Query operator~(const Query&);
friend Query operator&(const Query&, const Query&);
friend Query operator|(const Query&, const Query&);
public:
Query(const std::string&);//构建一个新的WordQuery查询的构造函数
//接口类的eval和rep调用的QueryBase派生类实现的eval和rep
QueryResult eval(const TextQuery& t) const { return q->eval(t); }
std::string rep() const { return q->rep(); }
private:
Query(std::shared_ptr<QueryBase> query) :q(query) {}//私有构造函数,被重载运算符调用
std::shared_ptr<QueryBase> q;
};
inline std::ostream& operator<<(std::ostream& os, const Query& query)
{
//通过他的指针来对rep()进行需调用
return os << query.rep();
}
inline Query::Query(const std::string& s) : q(new WordQuery(s)) {}
#endif
Query类中接受string的构造函数会创建一个WordQuery对象,然后把它绑定在shared_ptr指针上。
派生类:
派生类就要考虑具体的如何实现查询了,WordQuery类最直接,他的任务就是单词查询。而其他三类(姑且叫他逻辑查询类)的运算对象都可以是这三种类。为了支持这种灵活性,我们把这些运算对象用QueryBase指针的形式存储。然而回忆一下我们之前的设计,我们并不直接使用QueryBase类,而是使用Query接口类。因此上述三个逻辑查询类保存的并不是QueryBase指针,而是使用一个Query对象,该对象中保存了QueryBase指针。
和QueryBase类一样,WordQuery类也没有公共成员,必须通过Query类访问,因此WordQuery类必须把Query声明为友元。在WordQuery类中共,只有一个string成员表示要查询的单词。
//WordQuery.h
#pragma once
#ifndef WORD_QUERY_H
#define WORD_QUERY_H
#include "QueryBase.h"
class WordQuery :public QueryBase
{
friend class Query;//Query使用wordQuery构造函数
WordQuery(const std::string& s) :query_word(s) {}
//wordQuery类的查询操作就是直接调用TextQuery的查询操作,返回一个QueryResult类型的结果
QueryResult eval(const TextQuery& t) const { return t.query(query_word); }
std::string rep() const { return query_word; }//查询指令的string形式就是查询单词
std::string query_word;//要查找的单词
};
#endif
NotQuery类和wordQuery的声明类似,其中保存着一个需要对其取反的Query(这个Query实际上会绑定一个WrodQuery)。NotQuery的rep函数会输出~(word)的形式。取反运算符会创建一个NotQuery对象,然后返回一个Query对象,该Query对象会把shared_ptr绑定到这个NotQuery对象上。取反运算符在return中通过隐式调用Query对象的私有构造函数构建一个Query对象并返回。
//NotQuery.h
#ifndef NOT_QUERY_H
#define NOT_QUERY_H
#pragma once
#include"Query.h"
class NotQuery : public QueryBase {
friend Query operator~(const Query&);
NotQuery(const Query& q) :query(q) {}
std::string rep() const { return "~()" + query.rep() + ")"; }
QueryResult eval(const TextQuery&) const;//这里的实现比较复杂我们在cpp中实现
Query query;
};
inline Query operator~(const Query& operand)
{
return std::shared_ptr<QueryBase>(new NotQuery(operand));
}
#endif // !NOT_QUERY.H
BinaryQuery类也是一个抽象基类,他保存操作两个运算对象的查询类型(And和Or)所需要的数据。
//Binary.h
#ifndef BINARY_QUERY_H
#define BINEAY_QUERY_H
#pragma once
#include "Query.h"
class BinaryQuery :public QueryBase
{
protected:
BinaryQuery(const Query& l, const Query& r, std::string s) :lhs(l), rhs(r), opSym(s) {}
//Binary不定义eval
std::string rep() const { return "(" + lhs.rep() + " " + opSym + " " + rhs.rep() + ")"; }
Query lhs, rhs;//左侧和右侧的运算对象
std::string opSym;//运算符的名字
};
#endif
AndQuery类和OrQuery类会继承BinaryQuery抽象基类。rep()在BinaryQuery类中已经实现过了,因此无需再实现,继承过来即可。而and和or的eval逻辑是相冲突的,因此BinaryQuery类不定义eval,而是继承这个纯虚函数,eval具体由AndQuery和OrQuery来实现。
AndQuery和OrQuery中将各自的运算符定义成友元,并且各自定义了一个构造函数通过运算符创建BinaryQuery基类部分。继承基类的rep函数,覆盖eval函数。同~运算符一样,&和|运算符页返回一个绑定到新分配对象上的shared_ptr,在return语句中隐式调用Query的私有构造函数。
//AndQuery.h
#ifndef AND_QUERY_H
#define AND_QUERY_H
#pragma once
#include "BinaryQuery.h"
class AndQuery : public BinaryQuery
{
friend Query operator&(const Query&, const Query&);
//AndQuery的数据成员继承子BinaryQuery,构造函数中调用BinaryQuery的构造函数
AndQuery(const Query& l, const Query& r) : BinaryQuery(l, r, "&") {}
QueryResult eval(const TextQuery&) const;
};
inline Query operator&(const Query& lhs, const Query& rhs)
{
return std::shared_ptr<QueryBase>(new AndQuery(lhs, rhs));
}
#endif // !AND_QUERY_H
//OrQuery.h
#include"Query.h"
#include"QueryBase.h"
#include"BinaryQuery.h"
class AndQuery:public BinaryQuery
{
friend Query operator|(const Query &,const Query&);
//AndQuery的数据成员继承子BinaryQuery,构造函数中调用BinaryQuery的构造函数
OrQuery(const Query&l,const QUery&r): BinaryQuery(l,r,"|"){}
QueryResult eval(const TextQuery& )cosnt;
};
inline Query operator|(const Query &l,const Query&r)
{
return std::shared_ptr<QueryBase>(new OrQuery(l,s));
}
eval函数
eval函数是我们查询系统的核心,每个eval函数作用域各自的运算对象,执行特有的查询逻辑。为了支持上述eval的操作,我们会用到QueryResutl类(详细见:)。我们将扩展QueryResutl类,添加名为begin和end的成员,返回一个迭代器,指向一个给定查询返回的行号 的set中的位置,再添加一个名为get_file的成员,返回一个shared_ptr,指向QueryResult对象中的文件。
//QueryResult.h
#pragma once
#ifndef QUERY_RESULT_H
#define QUERY_RESULT_H
#include "tools.h"
class QueryResult {
friend std::ostream& print(std::ostream&, const QueryResult&, const std::string&);//把print函数声明为友元以便可以访问QueryResult对象的成员
public:
using line_no = std::vector<std::string>::size_type;//set中保存的是vector<stirng>的下标
using it = std::set<line_no>::iterator;
QueryResult(std::string s, std::shared_ptr<std::set<line_no>>p,
std::shared_ptr<std::vector<std::string>> f) :
sought(s), lines(p), file(f) {
}//由于使用了shared_ptr因此不会拷贝数据,只增加对应内存的引用计数
it begin() const { return lines->begin(); }
it end() const { return lines->end(); }
std::shared_ptr<std::vector<std::string>> get_file() const { return file; }
private:
std::string sought;
std::shared_ptr<std::set<line_no>> lines;
std::shared_ptr<std::vector<std::string>> file;
};
#endif
OrQuery表示两个运算对象结果的丙级,对于每个运算对象来说,我们通过调用eval得到查询结果。这些运算对象类型都是Query,所以调用eval实际上调用Query::eval,而后者实际上对潜在的QueryBase对下个你的eval进行虚调用。每次调用完成后,得到的结果是一个QueryResult,他表示运算对象出现的行号。我们把这些行号组织在一个新set中:
//OrQuery.cpp
#include "OrQuery.h"
//OrQuery.cpp
QueryResult OrQuery::eval(const TextQuery& text) const
{
//通过Query成员lhs和rhs进行虚调用,rhs和lhs中保存的指针实际指向WordQuery类型
//right保存的是右侧运算对象的查询结果,left保存的是左侧运算对象的查询结果
//这些结果都是QueryResult类型
auto right = rhs.eval(text), left = lhs.eval(text);
//将左侧运算符的行号拷贝到结果set中
auto ret_lines = std::make_shared<std::set<line_no>>(left.begin(), left.end());
//插入右侧运算对象所得到的行号
ret_lines->insert(right.begin(), right.end());
return QueryResult(rep(), ret_lines, left.get_file());//构造合并后的结果
}
AndQuery的eval和OrQuery很类似,我们在AndQuery中调用标准库算法setintersection来合并两个set。set_intersection的最后一个形参是目标迭代器,我们无法直接使用ret_lines->begin(),因为set的迭代器是const迭代器,不允许直接写入。因此我们需要用到插入迭代器。
inserter(*ret_lines,ret_lines->begin()),创建一个插入迭代器,第一个形参是容器,第二个形参是插入元素的位置迭代器。
//AndQuery.cpp
#include "AndQuery.h"
#include "tools.h"
//AndQuery.cpp
QueryResult AndQuery::eval(const TextQuery& text) const
{
auto left = lhs.eval(text), right = rhs.eval(text);
auto ret_lines = std::make_shared<std::set<line_no>>();
set_intersection(left.begin(), left.end(), right.begin(), right.end(), inserter(*ret_lines, ret_lines->begin()));
return QueryResult(rep(), ret_lines, left.get_file());
}
NotQuery查找运算对象没有出现的文本行,我们遍历素有的文本行,如果不在 result的行号中,就放入新的set中
//NotQuery.cpp
#include "NotQuery.h"
QueryResult NotQuery::eval(const TextQuery& text) const
{
auto result = query.eval(text);
auto ret_lines = std::make_shared<std::set<line_no>>();
auto beg = result.begin(), end = result.end();
//对于输入文件的每一行,如果改行不在result中,则将其添加到ret_lines中
auto sz = result.get_file()->size();//遍历次数
//遍历的时候因为都是升序排列的,因此如果不存在直接把行号添加在ret_lines中,如果存在,则n+1,并且beg往后移动。直到beg==end,此时后面所有的行号都添加到ret_lines中
for (size_t n = 0; n != sz; ++n) {
//检查当前行是否存在
if (beg == end || *beg != n)
ret_lines->insert(n);
else
++beg;//是否继续获取result的下一行
}
return QueryResult(rep(), ret_lines, result.get_file());
}
最后我们给一个main函数来把上面组合在一起
//main.cpp
#include "tools.h"
#include "Query.h"
#include "AndQuery.h"
#include "OrQuery.h"
#include "NotQuery.h"
//#include"TextQuery.h"
using std::cout;
using std::endl;
void runQueries(std::ifstream&);
int main() {
std::ifstream infile;
infile.open("C:\\Users\\cei\\Desktop\\test.txt");
if (!infile.is_open()) {
std::cerr << "读取文件失败" << std::endl;
return 1;
}
TextQuery tq(infile);
Query wordq1("tested");//单词查询
Query wordq2("as"); //单词查询
Query wordand = ~wordq1 | wordq2;
print(std::cout, wordand.eval(tq), wordand.rep()) << std::endl;
}
踩得坑
循环依赖
在最初的版本中,并没有注意到头文件的循环依赖问题,在Query.h和AndQuery.h相互包含,在编译阶段会无限递归或者类不完全,导致用IDE(visual studio 2022)编译的时候出现各种找不到类的问题。
在定位到是循环依赖问题后,最简单的一个解决方法便是在头文件中使用前向声明替代include。在本程序中我们也可以根据前面1.1节的内容设计头文件的依赖。
从根开始看起,我们在QueryBase的头文件中引入TextQuery,而作为接口的Query引入QueryBase,BinaryQuery继承QueryBase,因此也引入QueryBase。这样其他派生类只需导入对应的基类头文件即可在eval函数中使用TextQuery类。
我们通过重载位运算符来实现组合查询,与或非分别负责创建相应的派生查询,而这些派生的查询最终还是要回到WordQuery上(直观理解最终都要创建Query(“string”)对象),我们在Query的接受string类型的构造函数中new了一个WordQuery的对象。因此我们在Query头文件中还需要引入WordQuery头文件。
在BinaryQuery查询包含两个运算对象,这两个运算对象都是Query类型,因此需要包含Query头文件。
当我们梳理完上述依赖后,我们只需在派生类中包含相应抽象基类的头文件即可。其中与或非都需要创建Query对象,而与和或包含的BinaryQuery抽象基类中已经包含了Query,因此我们需要额外在NotQuery类中包含一下Query对象。而WordQuery可以看作最底层的查询(所有的查询最终都基于单词查询),他只需要包含抽象基类QueryBase即可。
标准库类引用:
不同的类中使用了不同的标准库设施,可能会出现重复引用的情况,解决办法,顶一个工具头文件,把所有需要用到的标准库都放在里面,引用的时候只需引用这一个头文件。
标准输入相关
关于 operator<<
重复定义的问题,通常是因为在头文件中定义了非内联的全局函数,导致多个编译单元(.cpp
文件)包含该头文件后出现重复符号。
在本程序实现中,如果我们把输出运算符重载定义在了Query.h文件中但没有使用inline关键字。会导致包含Query头文件的cpp文件重复定义。解决办法:使用inline,对Query.h使用头文件保护符
与或非运算符的可见性问题:
在我们设计的程序中,与或非运算符分别定义在AndQuery、OrQuery和NotQuery类中,我们使用的收需要包含这些类的头文件,然而我们希望用户只包含Query头文件。于是我们想到再Query头文件中包含这些类,但是这又回到了上面提到的循环引用问题上。究其本质还是因为我们为了简化设计,在与或非类中使用了Query类,包含了该类的头文件,因此Query类如果也包含与或非的头文件就会造成循环依赖。如果我们在与或非中使用前置声明替代头文件,又会出现未定义就使用的情况,因为我们在binaryQuery中使用了Query类的rep函数,同理我们在Not中也用到了Query的rep函数。
因此在使用过程中我们需要导入Query和与或非Query类。但用户代码只需要用到Query类即可。
目前没有找到很好的解决办法,临时的解决办法如下:我们可以把这四个类的头文件写道一个新的UserQuery.h中提供给用户。
总结
在本程序中,我们首先定义了一个抽象基类,然后派生出单词查询,非查询和一个二元查询抽象基类。二元查询抽象基类又派生出与和或查询。我们通过重载与或非运算符来创建相应的与或非查询对象。
接着我们定义了一个Query类作为接口类对用户隐藏继承体系。我们在Query的成员中保存了指向QueryBase类的指针,以便可以虚调用派生类的查询对象。
由于程序支持组合查询,因此在不同的派生类中都有可能创建其他派生类对象。例如与查询(派生类)的两个运算对象可能是或查询(派生类)。因此我们也需要用到指向QueryBase的指针来实现这一点。于是我们自然而然的就在这些派生类中使用Query接口类来支持上述查询。
附录-项目结果展示