分类 编程语言 下的文章

一、const和成员函数的故事

const的用途有以下几种:

  1. 修饰全局、局部、成员变量
  2. 修饰成员函数

修饰变量的时候const限制了变量在整个程序运行期间都是不能修改的,而修饰成员函数的时候限制函数内不能修改数据成员,这应该是所有C++程序员都烂熟于心的准则。但除了这两条准则以外,const还有一些隐含的准则,是写代码的时候是很容易被忽视的。例如以下代码,相信很多人都无法一眼看出这段代码的问题在哪:

class Hero {
private:
    std::string name;
public:
    Hero(const std::string &name) :name(name) {
    }

    ~Hero() {}

    std::string GetName() {
        return name;
    }
};

void PrintHeroName(const Hero &h) {
    std::cout << h.GetName() << std::endl;
}

其实问题很简单:PrintHeroName函数的形参是一个const类型的Hero对象,但是在函数体内,这个const类型的Hero对象调用了一个非const的成员函数,这是不允许的。这种情况下,编译器会报错:

const_cast.cpp:19:18: error: 'this' argument to member function 'GetName' has type 'const Hero', but function is not marked const
    std::cout << h.GetName() << std::endl;
                 ^
const_cast.cpp:13:17: note: 'GetName' declared here
    std::string GetName() {
                ^
1 error generated.

如果想要在PrintHeroName函数中调用h.GetName方法,只需要把形参里的const去掉就可以了。或者把GetName修改成一个const属性的成员函数。

其实最开始我也没有发现这段代码的问题,作为亲手写下这段代码的始作俑者,我甚至还捉摸了一段时间。但这其实就是一个简单的用法,因为使用得过于习惯,并且自认为非常熟悉,所以就忽视了问题所在。

二、为什么要使用const_cast

const_cast的作用是去掉变量的const或者volatile限定符——这看起来很鸡肋,因为很多人都在想:我既然要去掉const限制,那我直接不使用const修饰不就完事了吗?何必多此一举?其实最开始我也是这么认为的,所以从来没有用过这个关键字。直到今天遇到上面的那个问题之后,才突然明白const_cast的真正用途。

上面的程序,把PrintHeroName形参的const去掉后,程序可以正常编译了。问题是,如果调用它的上层函数,Hero对象已经被const限定了,应该怎么办?例如:

void PrintHeroName(Hero &h) {
    std::cout << h.GetName() << std::endl;
}

void CreateHero(const Hero &h) {
    PrintHeroName(h);
}

可以看到,CreateHero的形参是const的,但PrintHeroName是非const的,无法在CreateHero函数中将h传递到PrintHeroName,因为不能直接将一个const对象转换成非const的。程序编译也会报错:

const_cast.cpp:23:5: error: no matching function for call to 'PrintHeroName'
    PrintHeroName(h);
    ^~~~~~~~~~~~~
const_cast.cpp:18:6: note: candidate function not viable: 1st argument ('const Hero') would lose const qualifier
void PrintHeroName(Hero &h) {
     ^
1 error generated.

这种情况下就要用到const_cast了,这里才是const_cast大展拳脚的地方:因为我们很明确能知道GetName()是不会修改任何成员对象的值的,所以可以在这里通过const_cast去掉Hero的const限定,使得程序可以正常往下调用。即,只要把h通过const_cast包裹起来就可以了:

void CreateHero(const Hero &h) {
    PrintHeroName(const_cast<Hero &>(h));
}

一、前言

昨天在公司做代码扫描,发现很多类似以下的代码都产生了告警,导致扫描不通过:

virtual int func() override {}

不通过的原因是:同时使用virtual和override关键字来修饰成员函数,virtual关键字是多余的,要删掉。

说实话,刚开始看到错误提示的时候有点懵,因为对这个特性并不是很了解(代码也不是我写的),所以一时之间也不知道到底是什么原因,只是贸然按照提示把virtual关键字删掉了(删掉了就好了),回来研究了一阵之后才搞明白。

二、override和final

2.1 用途

override和final是C++11中的新特性,主要用于类继承时对虚函数的控制:

  • override修饰子类成员函数,表明当前成员函数覆盖了父类的成员函数。
  • final修饰父类成员函数,表明当前成员函数不能被覆盖。

其实看到这里我心里有一个疑惑:加了virtual关键字就可以实现覆盖了,为什么要用override呢?C++ Primer对这个问题的解释是:

派生类可能定义了一个和父类名字相同但是形参列表不同的成员函数,对编译器而言这不是非法的,这可能就导致不可预期的错误。可能我们是想覆盖父类的函数,但是因为不小心弄错了,最后编译器也没能帮我们检查出来。

加上override关键字之后,如果子类的函数在父类没有相同的函数名以及形参定义,编译器会报错。这就避免了因为开发人员不小心导致的意外错误。

因此,总结来看,override的作用主要是:

  1. 减少程序员因为大意出错的可能性
  2. 提高代码可读性,读代码的人一看到override就能直观的知道当前函数是覆盖了父类的虚函数

2.2 示例

class Hero {
public:
    virtual void SkillR(Hero &b) {};
};
 
class Soldier : public Hero{
public:
    Soldier() {}
     
    void SkillR(Hero &b) override {}
};

以上定义了一个英雄类和一个继承于它的战士类,战士类继承了父类的R技能SkillR(),它的函数名和形参列表和父类一模一样,加上override之后是没有问题的。但是如果把SkillR的形参去掉,编译时就会报错。

override.cpp:15:19: error: non-virtual member function marked 'override' hides virtual member function
override.cpp:8:18: note: hidden overloaded virtual function 'Hero::SkillR' declared here: different number of parameters (1 vs 0)
    virtual void SkillR(Hero &b) {};
                 ^
1 warning and 1 error generated.

三、override和virtual

回到问题本身,为什么virtual碰到override会失效?

当我使用clion编写上面的IDE也提示virtual是多余的:

原因:

cppreference.com中找到override的定义为:

Specifies that a virtual function overrides another virtual function.

意思是说,override指定函数是一个覆盖了其他类虚函数的虚函数,它本身的定义就是一个虚函数。相当于override=virtual+重写,因此virtual关键字也就多余了。

背景:我们的设备上有个链路探测的功能,会定时请求公网的某个IP地址,以探测网络是不是连通的。具体的做法是会使用icmp或dns探测远端服务器,看请求能否正常响应,如果有响应,则认为链路正常,否则则认为不正常,需要采取对应的措施。但是问题的现象是每隔一段时间后,探测包就收不到回复了,导致我们认为线路异常。而实际上网络还是通的,使用系统自带的ping和nslookup工具也是没问题的。

最后抓包分析,怀疑是IP数据包中的identify字段为0导致的,因为不回复的都是为0的id:

因此,我们就打算先把这id改掉试试。本身的实现上,我们使用的是原始套接字来构造icmp和dns请求,没办法控制ip.id。要想修改ip.id,必须让内核放弃自动填充ip头的操作。要想做到这一点,需要用到socket选项中的IP_HDRINCL选项,它的作用就是告诉内核不要填充头部:

val = 1;
setsockopt(sock, IPPROTO_IP, IP_HDRINCL, &val, sizeof(val));

注意事项:

  1. IP报文中,如果校验和设置为0,内核会帮我们自动填充。但在ICMP报文中,内核不会自动填充。
  2. 如果不填写源地址,内核也会自动帮我们填充。

以下是一个自己修改的PING包示例代码:

#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/in_systm.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>

/*
 * 计算校验和的函数
 * @ptr 待计算校验和的数据
 * @nbytes 数据长度
 * @return 返回校验和
 */
unsigned short check_sum(unsigned short *ptr, int nbytes) {
    register long sum;
    unsigned short oddbyte;
    register short answer;

    sum = 0;
    while (nbytes > 1) {
        sum += *ptr++;
        nbytes -= 2;
    }

    if (nbytes == 1) {
        oddbyte = 0;
        *((unsigned char *)&oddbyte) = *(unsigned char *)ptr;
        sum += oddbyte;
    }

    sum = (sum >> 16) + (sum & 0xffff);
    sum = sum + (sum >> 16);
    answer = (short)~sum;

    return (answer);
}

int main(int argc, char *argv[]) {
    int sock, val;
    char buf[1024];
    // IP包头
    struct iphdr *iph = (struct ip *)buf;
    // ICMP包头
    struct icmphdr *icmph = (struct icmphdr *)(iph + 1);

    socklen_t addr_len;
    struct sockaddr_in dst;
    struct sockaddr_in src_addr, dst_addr;

    if (argc < 3) {
        printf("\nUsage: %s <saddress> <dstaddress>\n", argv[0]);
        return 0;
    }

    bzero(buf, sizeof(buf));

    // 创建原始套接字
    if ((sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)) < 0) {
        perror("socket() error");
        /* If something wrong, just exit */
        return -1;
    }

    val = 1;
    // 告诉内核我们自己填充IP头部
    if (setsockopt(sock, IPPROTO_IP, IP_HDRINCL, &val, sizeof(val)) < 0) {
        perror("setsockopt() for IP_HDRINCL error");
        return -1;
    }

    // 填充IP头部
    iph->ihl = 5; // ip头部的长度/4
    iph->version = 4; // 版本信息
    iph->tos = 0;
    iph->tot_len = sizeof(struct iphdr) +
                   sizeof(struct icmphdr);  // 总长度等于ip头部+icmp总长度
    iph->id = htons(4321);
    iph->frag_off = 0;
    iph->ttl = 128;
    iph->protocol = IPPROTO_ICMP;
    iph->check = 0; // 让内核自己去计算校验和
    iph->saddr = inet_addr(argv[1]);
    iph->daddr = inet_addr(argv[2]);
    // check sum
    // iph->check = check_sum((unsigned short *)buf, iph->tot_len);

    dst.sin_addr.s_addr = iph->daddr;
    dst.sin_family = AF_INET;

    // 添加ICMP包头
    icmph->type = ICMP_ECHO;
    icmph->code = 0;
    icmph->checksum = 0;
    icmph->un.echo.id = htons(9987);
    icmph->un.echo.sequence = htons(9988);

    // 首部检验和
    icmph->checksum = check_sum((void *)icmph, sizeof(struct icmphdr));

    addr_len = sizeof(dst);

    // 发数据
    val = sendto(sock, buf, iph->tot_len, 0, (struct sockaddr *)&dst, addr_len);
    if (val < 0) {
        perror("sendto() error\n");
    } else {
        printf("sendto() is OK\n");
    }

    // 收数据
    val = recvfrom(sock, buf + 30, sizeof(buf) - 30, 0, NULL, NULL);
    if (val < 0) {
        perror("recv from error");
    } else {
        printf("recv %d bytes data\n", val);
        iph = (void *)(buf + 30);
        icmph = (struct icmphdr *)(iph + 1);
        printf("icmp type: %d, icmp code = %d, seq = %u, id = %u\n",
               icmph->type, icmph->code, ntohs(icmph->un.echo.sequence),
               ntohs(icmph->un.echo.id));
    }

    // 关闭socket
    close(sock);

    return 0;
};

问题场景:服务器有多个网卡,分别绑定了地址A、地址B和地址C,当客户端连接请求过来的时候,如何知道是从哪个IP地址请求过来的?

解决方案:服务端accept客户端的连接后,对这个新接受的socket进行getsockname就可以了。

示例代码:

fd = accept(listen_fd, (struct sockaddr *)&addr, &addr_len);
if (fd < 0) {
    // xxxx
} else {
    getsockname(fd, (struct sockaddr *)&fd_addr, &addr_len);
    // fd_addr此时就保存了客户端连接过来的目的地址
}

假设客户端地址是1.1.1.1,通过connect(2.2.2.2)过来,此时拿到的就是2.2.2.2这个地址。

拓展

getsockname和getpeername:

  • getsockname:获取当前socket绑定的地址,执行bind后可以获取当前socket的地址信息,执行accept后可以获取对端连入的地址。
  • getpeername:获取对端socket绑定的地址,connect或者accept后可以得到对方的地址。

客户端的socket不需要手动执行bind绑定地址,但这不意味着客户端socket真的不需要绑定端口,实际上是内核它帮我们做了这个操作,在执行connect时,内核发现没有绑定端口,就会自动选择一个合适的端口绑定到socket。

当然这不说明我们不能对客户端socket执行bind操作,对于客户端socket,依旧可以执行bind操作把socket绑定到一个地址。

为什么客户端的socket会自动分配端口呢?

  1. 客户端的socket很多,每产生一个连接,就会创建一个socket,我们无法准确得知有哪些端口没有被使用。
  2. 客户端的socket并不关心自己端口是多少,更多的关注是服务端的端口号,因此客户端socket可以任意指定,只要能够通信即可。

自动分配端口这一操作在内核中的体现在net/inet_connection_sock.c:inet_csk_get_port()

do {
    head = &hashinfo->bhash[inet_bhashfn(net, rover,
            hashinfo->bhash_size)];
    spin_lock(&head->lock);
    // 遍历所有的连接哈希表
    inet_bind_bucket_for_each(tb, node, &head->chain)
        // 端口已经被使用了
        if (ib_net(tb) == net && tb->port == rover) {
            // 判断是否开启了端口快速回收以及端口重用
            if (((tb->fastreuse > 0 &&
                sk->sk_reuse &&
                sk->sk_state != TCP_LISTEN) || 
                (tb->fastreuseport > 0 &&
                  sk->sk_reuseport &&
                  tb->fastuid == uid))&&
                (tb->num_owners < smallest_size || smallest_size == -1)) {
                // 处理端口重用逻辑
                smallest_size = tb->num_owners;
                smallest_rover = rover;
                if (atomic_read(&hashinfo->bsockets) > (high - low) + 1) {
                    spin_unlock(&head->lock);
                    snum = smallest_rover;
                    // 找到了一个合适的端口
                    goto have_snum;
                }
            }
            goto next;
        }
    break;
next:
    // 继续往下遍历端口号,直到找到一个可用的端口
    spin_unlock(&head->lock);
    if (++rover > high)
        rover = low;
} while (--remaining > 0);

就像上面所描述的,大多数场景,我们无法确切知道当前环境还有哪些端口可用,因此也无需绑定地址到socket。但是对于特定场景,还是需要给socket手动绑定地址。如代理程序对UDP代理时,要先在本地创建一个socket作为客户端发送数据的隧道,这个时候就要先指定好端口,然后再返回给客户端。

那么现在问题就来了,我怎么知道需要绑定哪个端口呢?上面也说了,我不知道有哪些可用的端口。同时,因为也没有执行connect,此时也没有得到一个随机的端口,这种场景应该怎么处理?可以采取的做法是:给socket绑定一个0端口,让系统随机分配一个。

代码中赋值add.sin_port = 0,就是告诉内核,我需要一个可用的随机的端口,请给我分配一个。然后内核就会分配了。

执行完后,再通过getsockname函数就可以得到系统分配的端口号了。示例代码:

int main() {
    int sock_fd;
    struct sockaddr_in addr;
    socklen_t socklen;

    sock_fd = socket(AF_INET, SOCK_STREAM, 0);

    addr.sin_family = AF_INET;
    addr.sin_port = 0; // 绑定随机端口
    addr.sin_addr.s_addr = INADDR_ANY;

    socklen = sizeof(addr);
    if (bind(sock_fd, (struct sockaddr *)&addr, socklen)) {
        perror("bind error");
        return -1;
    }

    // 获取系统随机分配的端口号
    getsockname(sock_fd, (void *)&addr, &socklen);
    printf("%u\n", ntohs(addr.sin_port));
    return 0;
}

在写代码的过程中,CLion提醒我把push_back方法替换成emplace_back方法:

代码中我的想法是使用vector创建一个二维数组,并提前分配好空间,避免后序频繁扩容增加时间复杂度。

emplace_back函数的作用是减少对象拷贝和构造次数,是C++11中的新特性,主要适用于对临时对象的赋值。

在使用push_back函数往容器中增加新元素时,必须要有一个该对象的实例才行,而emplace_back可以不用,它可以直接传入对象的构造函数参数直接进行构造,减少一次拷贝和赋值操作。

例如以下学生类:

class stu_info {
private:
    string name;
public:
    stu_info(const string &name) {
        this->name = name;
        cout << "stu_info(): " << this->name << endl;
    }

    ~stu_info() {
        cout << "~stu_info(): " << this->name << endl;
    }

    stu_info(const stu_info &s) {
        this->name = s.name;
        cout << "copy constructor: " << this->name << endl;
    }
};

使用push_back插入元素的办法:

vector<stu_info> v;
v.push_back(stu_info("nginx"));

在push_back之前,必须使用stu_info实例一个临时对象传入才行,实例对象就必须要执行构造函数,然后拷贝到容器中再执行一次拷贝构造函数。

而emplace_back可以不用执行多余的拷贝构造函数了,它是直接在容器内执行对象的构造:

vector<stu_info> v;
v.emplace_back("redis");

两个函数的执行结果:

我以前确实是还不知道这个函数,要不是编译器提醒我,以后我可能也不会知道。不得不说IDE在某些方面也能帮助我们提高眼界

问题背景:在刷题的过程中,要使用min函数,但是线上OJ并没有这个函数。因为一时也想不起它到底属于哪个头文件,所以为了偷懒,顺手就写下了以下宏定义:

#define min(x, y) (x) < (y) ? (x) : (y)

正常情况下这个宏定义是没有问题的,代码提交错误我也从没怀疑过它有问题。因为我认为自己对宏定义已经十分了解了,它的坑我基本都遇到过,该写的括号都写了,只是没有加do...while(0)而已,应该不会有问题。

直到我提交失败了n次后,当我抱着试一试的态度把这个宏定义替换成了内联函数后,提交就过了:

static inline int min(int x, int y) {
    return x < y ? x : y;
}

此时我的心里就只有两个字:卧槽!为什么我不早点开启调试呢?因为错误案例的数据量特别大,调试到触发问题的点太耗时了,所以一直没有调试。触发问题出现的场景是我对宏定义进行了嵌套调用:

x = min(min(1, 3), 2) + 1

使用-E选项预处理发现他们被展开成了如下形式,预期的结果应该返回2,但是这个表达式返回的结果是1,所以就出现了问题:

x = ((1) < (3) ? (1) : (3)) < (2) ? ((1) < (3) ? (1) : (3)) : (2) + 1;

《Effective C++》中明确提出了一点就是:少使用宏定义!宏定义只是简单的文本替换,它不会在编译时候检查,在复杂的表达式逻辑中很容易就会产生问题。

刷OJ的时候惊喜的发现,我竟然不会给二维数组动态分配内存。写了n年的代码了,竟然被这个难倒了!自惭形秽,难以言表。

方法一

先分配指针数组的内存,然后给数组中的每个int *指针分配内存:

int **data, i;
data = (int *)malloc(sizeof(int *) * row);

for (i = 0; i < row; i++) {
    data[i] = (int *)malloc(sizeof(int) * col);
}

数组的表现形式:

方法二

使用一维指针模拟成二维指针,分配row * col个连续的元素,访问的时候模拟成二维指针访问:

data = (int *)malloc(sizeof(int) * row * col);

for (i = 0; i < row; i++) {
    for (j = 0; j < col; j++) {
        *(data + i * col + j) = i * j;
    }
}

参考

How to dynamically allocate a 2D array in C?

做代码优化,发现代码中获取系统CPU核数是通过system调用命令得到的,想想最近被system支配的恐惧,果断改掉。

linux c中获取CPU核数的函数原语有两个:

#include <sys/sysinfo.h>
get_nprocs_conf();
get_nprocs();

第二个函数是返回当前可用的CPU数量,不可用的意思是CPU HANG住了。如果安装了man page,可以直接在man page中找到用法。

#include <stdio.h>
#include <sys/sysinfo.h>

int
main(int argc, char *argv[])
{
   printf("This system has %d processors configured and "
           "%d processors available.\n",
           get_nprocs_conf(), get_nprocs());
   return 0;
}

一、我为什么不喜欢system和popen

要说到我为什么不喜欢system和popen这两个函数,这个说来就话长了。最开始,我还是很喜欢用这两个函数的,直到后来发现了太多因为滥用导致的程序异常后,它们就逐渐被我打入了冷宫。我认为一个设计良好的程序,完全是可以避开这两个函数的。这不,一周之内,我就收到了两个因为它们导致的BUG。

与其说我不喜欢这两个函数,倒不如说是不喜欢代码作者在不完全考虑好异常的情况下就使用它们^_^。

问题的现象是:线上程序功能失效,经过一番排查后,发现很多popen和system调用命令报错的日志。错误码是12,12的宏定义是ENOMEM,说明是无法分配足够的内存导致命令执行失败了。

strerror(ENOMEM) = "Alloc memory fail"

但是实际上设备的剩余内存还有好几百兆,所以这个错误有点让人摸不着头脑了。在网上查询了一波后,几乎全是说创建子进程时子进程完全拷贝了父进程的内存导致的。虽然我比较认同这个观点,因为这两个函数底层实现都是创建子进程执行命令,但是并没有一个人能讲述清楚为什么创建子进程时会拷贝父亲内存。

因为在学习多进程时听到最多的一句话是“读时共享,写时复制”,也就是说,子进程创建的时候,是共享父亲的内存的,不会完全拷贝内存到子进程,直到有数据修改才复制。这前后就逻辑就相违背了。

为了搞清楚这个问题,在google查了很久,也没有找到满意的答案。最后动手实践了一下,跟踪程序调用,发现是执行system时,程序并没有调用mmap来映射父进程的内存地址,想来system确实是不支持这个机制(虽然不知道结论是否正确,但从现象和调试过程来看,比较靠谱)。

二、strace调试

编写了一个简单的system调用程序:

#include <stdlib.h>

int main() {
    system("echo helloworld >> ~/test.txt");
    return 0;
}

编译,通过strace调用:

输出使用两个框框起来了,其中第一个框里面出现了大量的mmapmunmap,这些都是strace调用system命令产生的,不是system程序中的system()函数产生的。因为strace调试程序也是fork() + execve()来实现的(这个从第一行的输出就能看出来),这些mmap调用就是父子进程在映射共享内存,也就说明了正常情况下创建子进程确实是遵循“读时共享,写时复制”的。

但是重点是第二个红框标出来的部分,这里才是system程序执行部分。可以看到,这里的system()函数是直接通过clone来创建新进程的,创建完成后父进程就调用wait等待子进程退出了,并没有执行mmap这些函数来共享父进程内存,因此也就不支持COW原则。

三、参考

ENOMEM from popen() for system(), while there is enough memory