[C/C++常见笔试面试题] 程序设计基础 - 面向对象相关、虚函数、编程技巧篇

13 面向对象相关

面向对象思想是程序设计历史上一次伟大的创新,面向对象的提出极大地提高了程序设计的效率,为程序设计的重用性奠定了坚实的基础,面向对象思想已经广泛应用在现今主流的编程语言中,如C++、Java、C#等。

13.1 面向对象与面向过程有什么区别?

面向对象

面向对象是把数据及对数据的操作方法放在一起,作为一个相互依存的整体,即对象。对同类对象抽象出其共性,即类,类中的大多数数据,只能被本类的方法进行处理。类通过一个简单的外部接口与外界发生关系,对象与对象之间通过消息进行通信。程序流程由用户在使用中决定。例如,站在抽象的角度,人类具有身高、体重、年龄、血型等一些特性。人类仅仅只是一个抽象的概念,它是不存在的实体,但是所有具备人类这个群体的属性与方法的对象都叫人,这个对象人是实际存在的实体,每个人都是人这个类的一个对象。

面向过程

面向过程是一种以事件为中心的开发方法,就是自顶向下顺序执行,逐步求精,其程序结构是按功能划分为若干个基本模块,这些模块形成一个树状结构,各模块之间的关系也比较简单,在
功能上相对独立,每一模块内部一般都是由顺序、选择和循环三种基本结构组成的,其模块化实现的具体方法是使用子程序,而程序流程在写程序时就已经决定。
例如五子棋,面向过程的设计思路就是首先分析问题的步骤:第一步,开始游戏;第二步,黑子先走;第三步,绘制画面;第四步, 判断输赢;第五步,轮到白子;第六步,绘制画面;第七步,判断输赢;第八步,返回步骤2;第九步,输出最后结果。把上面每个步骤用分别的函数来实现,就是一个面向过程的开发方法。

区别

(1) 出发点不同。

面向对象是用符合常规思维方式来处理客观世界的问题,强调把问题的要领直接映射到对象及对象之间的接口上。而面向过程方法则不然,它强调的是过程的抽象化与模块化,它是以过程为中心构造或处理客观世界问题的。

(2) 层次逻辑关系不同。

面向对象方法则是用计算机逻辑来模拟客观世界中的物理存在,以对象的集合类作为处理问题的基本单位,用类的层次结构来体现类之间的继承和发展。而面向过程方法处理问题的基本单位是能清晰准确地表达过程的模块,用模块的层次结构概括模块或模块间的关系与功能,把客观世界的问题抽象成计算机可以处理的过程。

(3) 数据处理方式与控制程序方式不同。

面向对象方法将数据与对应的代码封装成一个整体,原则上其他对象不能直接修改其数据,即对象的修改只能由自身的成员函数完成。控制程序方式上是通过“事件驱动”来激活和运行程序。而面向过程方法是直接通过程序来处理数据,处理完毕后即可显示处理结果。在控制程序方式上是按照设计调用或返回程序,不能自由导航,各模块之间存在着控制与被控制、 调用与被调用的关系。

(4) 分析设计与编码转换方式不同。

面向对象方法贯穿软件生命周期的分析、设计及编码之间,是一种平滑过程,从分析到设计再到编码采用一致性的模型表示,即实现的是一种无缝连接。而面向过程方法强调分析、设计及编码之间按规则进行转换,贯穿软件生命周期的分析、设计及编码之间,实现的是一种有缝的连接。


13.2 面向对象的基本特征有哪些?

面向对象方法首先对需求进行合理分层,然后构建相对独立的业务模块,最后通过整合各模块,达到高内聚、低耦合的效果,从而满足客户要求。具体而言,它有3个基本特征:封装、继承和多态。

(1) 封装是指将客观事物抽象成类,每个类对自身的数据和方法实行保护。类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。C++中类是一种封装手 段,采用类来描述客观事物的过程就是封装,本质上是对客观事物的抽象。

(2) 继承可以使用现有类的所有功能,而不需要重新编写原来的类,它的目的是为了进行代码复用和支持多态。它一般有3种形式:实现继承、可视继承、接口继承。其中,实现继承 是指使用基类的属性和方法而无需额外编码的能力;可视继承是指子窗体使用父窗体的外观和实现代码;接口继承仅使用属性和方法,实现滞后到子类实现。前两种(类继承)和后一种 (对象组合=>接口继承以及纯虚函数)构成了功能复用的两种方式。

(3) 多态是指同一个实体同时具有多种形式,它主要体现在类的继承体系中,它是将父对象设置成为和一个或更多的它的子对象相等的技术,赋值以后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。简单地说,就是允许将子类类型的指针赋值给父类类型的指针。编译时多态是静态多态,在编译时就可以确定对象使用的形式。


13.3 什么是深拷贝?什么是浅拷贝?

如果一个类拥有资源(堆或者是其他系统资源),当这个类的对象发生复制过程时,资源重新分配,这个过程就是深拷贝;反之对象存在资源,但复制过程并未复制资源的情况视为浅拷贝。

例如,在某些状况下,类内成员变量需要动态开辟堆内存,如果实行位复制,也就是把对象里的值完全复制给另一个对象,如A=B,这时,如果类B中有一个成员变量指针已经申请了内存,那么类A中的那个成员变量也指向同一块内存。这就出现了问题:当B把内存释放 了,如通过析构函数,这时A内的指针就变成野指针了,导致运行错误。

深复制的程序示例如下:

#include <iostream> 

using namespace std; 

class CA 
{
public:
    CA(int b,char* cstr);
    CA(const CA& C); 
    void Show();
    〜CA(); 
private: 
    int a; 
    char *str;
};

CA::CA(int b,char* cstr)
{
    a=b;
    str=new char[b]; 
    strcpy(str,cstr);
}

CA::CA(const CA& C) 
{
    a=C.a;
    str=new char[a]; //给str重新分配内存空间,所以为深复制 
    if(str!=0)
        strcpy(str,C.str);
}

void CA::Show()
{
    cout<<str<<endl;
}

CA::〜CA()
{
    delete str;
}

int main()
{
    CA A(10,"Hello"); 
    CA B=A; 
    B.Show(); 
    
    return 0;
}

程序输出结果:

Hello

如果没有自定义复制构造函数时, 系统将会提供给一个默认的复制构造函数来完成这个过程,就会造成“浅拷贝”。所以要自定义赋值构造函数,重新分配内存资源,实现“深拷贝”。


13.4 什么是友元?

类具有封装、继承、多态、信息隐藏的特性,只有类的成员函数才可以访问类的标记为 private的私有成员,非成员函数可以访问类中的公有成员,但是却无法访问私有成员,为了使非成员函数可以访问类的成员,唯一的做法就是将成员都定义为public,但如果将数据成员都定义为公有的,这又破坏了信息隐藏的特性。

友元正好解决了这一棘手的问题。在使用友元函数时,一般需要注意以下几个方面的问题:

(1) 必须在类的说明中说明友元函数,说明时以关键字friend开头,后跟友元函数的函数原型,友元函数的说明可以出现在类的任何地方,包括private和public部分。

(2) 友元函数不是类的成员函数,所以友元函数的实现与普通函数一样,在实现时不用 “::”指示属于哪个类,只有成员函数才使用“::”作用域符号。

(3) 友元函数不能直接访问类的成员,只能访问对象成员。

(4) 调用友元函数时,在实际参数中需要指出要访问的对象。

(6) 类与类之间的友元关系不能继承。

友元一般定义在类的外部,但它需要在类体内进行说明,为了与该类的成员函数加以区别,在说明时前面加以关键字friend。需要注意的是,友元函数不是成员函数,但是它可以访问类中的私有成员。友元的作用在于提高程序的运行效率,但是它破坏了类的封装性和隐藏 性,使得非成员函数可以访问类的私有成员。

如下为一个友元函数的例子:

#include <iostream>
#include <string> 
using namespace std; 

class Fruit
{
public:
    Fruit(const string &nst="apple",const string &cst="green"):name(nst),colour(cst)
    {        
    }
    
    〜Fruit()
    {
    }
    friend istream& operator>>(istream&,Fruit&); 
    friend ostream& operator<<(ostream&,const Fruit&); 
    
    void print()
    {
    cout<<colour<<" "<<name<<endl;
    }

private:
    string name; 
    string colour;
};

ostream& operator<<(ostream &out,const Fruit &s) //重载输出操作符
{
    out<<s.colour<<" "<<s.name; 
    return out;
}

istream& operator>>(istream& in,Fruit &s) //重载输入操作符
{
    in>>s.co1our>>s.name; 
    
    if(!in)
        cerr<<"Wrong input!"<<endl; 
        
    return in;
}

int main()
{
    Fruit apple; 
    cin>>apple; 
    cout<<apple; 
    
    return 0;
}


13.5 基类的构造函数/析构兩数是否能被派生类继承?

基类的构造函数/析构函数不能被派生类继承。

基类的构造函数不能被派生类继承,派生类中需要声明自己的构造函数。在设计派生类的构造函数时,不仅要考虑派生类所增加的数据成员初始化,也要考虑基类的数据成员的初始化。声明构造函数时,只需要对本类中新增成员进行初始化,对继承来的基类成员的初始化, 需要调用基类构造函数完成。

基类的析构函数也不能被派生类继承,派生类需要自行声明析构函数。需要注意的是,析构函数的调用次序与构造函数相反。


13,6 类的成员变量的初始化顺序是按照声明顺序吗?

在C++中,类的成员变量的初始化顺序只与变量在类中的声明顺序有关,与在构造函数中的初始化列表的顺序无关。而且静态成员变量先于实例变量,父类成员变量先于子类成员变量。

示例程序如下:

class Test 
{
private :
    int nl; 
    int n2;     
public:
    Test();
};

Test::Test():n2(2),nl(1)
{}

当查看相关汇编代码时,就能看到正确的初始化顺序了。因为成员变量的初始化次序跟变量在内存中的次序有关,而内存中的排列顺序早在编译期就根据变量的定义次序决定了。

从全局看,变量的初始化顺序如下:

(1) 基类的静态变量或全局变量。

(2) 派生类的静态变量或全局变量。

(3) 基类的成员变量。

(4) 派生类的成员变量。


13.7 一个类为另一个类的成员变量时,如何对其进行初始化?

示例程序如下:

class ABC 
{
public:
    ABC(int x, int y, int z); 
private : 
    int a; 
    int b; 
    int c;
};

class MyClass 
{
public:
    MyClass():abc(1,2,3)
    {
        
    } 

private:
ABC abc;
};

上例中,因为ABC有了显式的带参数的构造函数,那么它是无法依靠编译器生成无参构造函数的,所以必须使用初始化列表:abc(1,2,3),才能构造ABC的对象。


13.8 C++中的空类默认产生哪些成员函数?

C++的空类是指这个类不带任何数据,即类中没有非静态(non-static)数据成员变量,没有虚函数(virtual function),也没有虚基类(virtual base class)。 直观地看,空类对象不使用任何空间,因为没有任何隶属对象的数据需要存储。然而,C++标准规定,凡是一个独立的(非附属)对象都必须具有非零大小。换句话说,C++空类的大小不为0,而是为1。为了验证这个结论,可以先来看测试程序的输出:

#include <iostream>
using namespace std;

class NoMembers
{
};

int main()
{
    NoMembers n;
    cout << "The size of empty class is: "<< sizeof(n) << endl;
}

程序输出结果:

The size of empty class is: 1

C++中空类默认会产生以下6个函数:默认构造函数、复制构造函数、析构函数、赋值运算符重载函数、取址运算法重载函数、const取址运算符重载函数等。

class Empty
{
public:
    Empty();//默认构造函数
    Empty( const Empty& );// 复制构造函数
    〜Empty();//析构函数
    Empty& operator=(const Empty&);// 赋值运算符
    Empty* operator&();// 取址运算待
    const Empty* operator&( ) const; // 取址运算符 const
};


13.9 C++提供默认参数的函数吗?

C++可以给函数定义默认参数值。在函数调用时没有指定与形参相对应的实参时,就自动使用默认参数。

默认参数的语法与使用:

(1) 在函数声明或定义时,直接对参数赋值,这就是默认参数。

(2) 在函数调用时,省略部分或全部参数。这时可以用默认参数来代替。

通常调用函数时,要为函数的每个参数给定对应的实参。例如:

void delay(int loops=1000);//函数声明 

void delay(int loops) //函数定义 
{
    if(loops==0)
    {
        return;
    }
    for(int i=0;i<loops;i++)
        ;
}

在上例中,如果将delay()函数中的loops定义成默认值1000,这样,以后无论何时调用delay()函数,都不用给loops赋值,程序都会自动将它当做值 1000进行处理。例如,当执行delay(2500)调用时,loops的参数值为显性化的,被设置为 2500;当执行delay()时,loops将采用默认值1000。

默认参数在函数声明中提供,当有声明又有定义时,定义中不允许默认参数。如果函数只有定义,则默认参数才可出现在函数定义中。例如:

oid point(int=3,int=4);//声明中给出默认值 

void point(int x,int y) //定义中不允许再给出默认值 
{
    cout<<x<<endl;
    cout<<y<<endl;
}

如果一组重载函数(可能带有默认参数)都允许相同实参个数的调用,将会引起调用的二 义性。例如:

void func(int);//重载函数之一 
void func(int,int=4);//重载函数之二,带有默认参数 
void func(int=3,int=4);//重载函数三,带有默认参数 
func(7);//错误:到底调用3个重载函数中的哪个? 
func(20,30);//错误:到底调用后面两个重载函数的哪个?


14 虚函数

虚函数中的“虚”并不是实际生活中虚拟的意思,因为没有“实”函数的说法。虚函数是面向对象编程中函数的一种特定形态,是C++中用于实现多态的一种有效机制。

14.1 什么是虚函数?

指向基类的指针在操作它的多态类对象时,会根据不同的类对象调用其相应的函数,这个函数就是虚函数。虚函数的作用是在程序的运行阶段动态地选择合适的成员函数,在定义了虚函数后,可以在基类的派生类中对虚函数进行重新定义。如果在派生类中没有对虚函数重新定义,则它继承其基类的虚函数。

#include<iostream> 
using namespace std;

class Base
{
public:
    virtual void Print()
    {
        printf("This is Class Base!\n");
    }
};

class Derived:public Base
{
public:
    void Print()
    {
        printf("This is Class Derived!\n");
    }
};

int main()
{
    Derived Cderived;
    Base Cbase;
    Base *p1 = &Cderived;
    Base *p2 = &Cbase;
    p1->Print();
    p2->Print();
}

程序输出结果:

This is Class Derived!
This is Class Base!

需要注意的是,虚函数虽然非常好用,但是在使用虚函数时,并非所有的函数都需要定义成虚函数,因为实现虚函数是有代价的。在使用虚函数时,需要注意以下几个方面的内容:

(1) 只需要在声明函数的类体中使用关键字virtual将函数声明为虚函数,而定义函数时不需要使用关键字virtual。

(2) 当将基类中的某一成员函数声明为虚函数后,派生类中的同名函数自动成为虚函数。

(3) 非类的成员函数不能定义为虚函数,全局函数以及类的成员函数中静态成员函数和构造函数也不能定义为虚函数,但可以将析构函数定义为虚函数。

(4) 基类的析构函数应该定义为虚函数,这样可以在实现多态的时候不造成内存泄漏。基类析构函数未声明virtual,基类指针指向派生类时,delete指针不调用派生类析构函数。有 virtual,则先调用派生类析构再调用基类析构。

(5) 基类指针动态建立派生类对象,普通调用派生类构造函数。


14.2 C++中如何阻止一个类被实例化?

C++中可以通过使用抽象类,或者将构造函数声明为private阻止一个类被实例化。抽象类之所以不能被实例化,是因为抽象类不能代表一类具体的事物,它是对多种具有相似性的具体事物的共同特征的一种抽象。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但是动物本身生成对象不合情理。


15 编程技巧

编程,容易;技巧,容易;编程技巧,不容易。

15.1 表达式a>b>c足什么意思?

在弄清这个问题前,先看如下代码:

#include <stdio.h> 

int main()
{
    int a=5,b=4,c=3; 
    printf("%d\n",a>b>c); 
    
    return 0;
}

程序输出结果:

0

对于这种连续运算,根据优先级,首先进行a>b 的比较判断,本例中a>b为真,所以返回值为1,接着比较该返回值与c的大小。因为c的值 为3,1>c表达式为假,所以返回值为0。所以,最终的输出为0。

对于赋值运算符,结果又如何呢?以如下程序为例。

#include <stdio.h>
int main()
{
    int b,c;
    int a=(b=(c=020)&&(1—2)); 
    printf("%d %d %d\n",a,b,c); 
    
    return 0;
}

在赋值语句中,c=020,因为以0开头的数字一般表示的都是八进制的数值,所以折合成十进制的数为16。根据优先级关系,b的值为(c=020)&&(1=2)的结果,由于c=020是一个赋值语句,所以该赋值语句的返回值为真,即为1,而1==2则为假,返回值为0,所以b的值为0,a=(b=0),所以a的值为0。


15.2 如何实现一个最简单病毒?

可以把最简单的病毒理解为一个无限运行的恶意程序,无限运行可以通过无限循环实现,而恶意可以通过申请内存空间来实现,所以可以用如下代码来实现一个最简单的病毒。

while(l)
{
    int *p=new int[10000000];
}

该代码首先新建一个无限循环,然后在循环内执行一个内存申请操作,最终系统内存会被该程序占用完,导致系统出现宕机的情况。


15.3 如何只使用一条语句实现x是否为2的若干次冥的判断?

如果一个数是2的若干次幂,那么其二进制表示中只有一位为1,其他位都为0,该数减去1之后的数的二进制表示为全1,所以将两数进行与操作,判断其最终结果是否为0,为0则说明是2的若干次冥。

程序示例如下:

int i = 512;
cout<<i&(i-1)?false:true<<endl;


15.4 \n是否与\n\r 等价?

换行(\n)就是光标下移一行却不会移到这一行的开头,回车(\r)就是回到当前行的开头却不向下移一行。

按(Enter〉键后会执行"\n\r",这样就是看到的一般意义的回车了,所以在用16进制文件查看方式看一个文本,就会在行尾发现"\n\r"。

Tab是制表符,就是"\t”,作用是预留8个字符的显示宽度,用于对齐。

猜你喜欢

转载自www.cnblogs.com/linuxAndMcu/p/10200961.html
今日推荐