1. 程式人生 > >實戰篇 | 20 Linux 網路通訊 與 C++ 11 編碼

實戰篇 | 20 Linux 網路通訊 與 C++ 11 編碼

一切皆檔案

Linux有一個非常高層次的抽象,它把我們計算機中所有的東西全都抽象出了檔案這麼一個東西,正常情況下,我們在硬盤裡建立檔案,讀寫內容。在Linux底下,它用這個檔案去抽象了很多東西。除了我們基本檔案,比如視訊檔案,文字檔案,還有一些特殊檔案。比如資料夾、管道檔案、負荷連線、硬連線這些都會以檔案的形式展現給我們。

除此以外,它把我們的硬體裝置也抽象成了檔案這麼一個 概念。

比如我們進入dev目錄,你會發現這裡有一堆的檔案,比如loop0loop1loop7. 如下圖:

但它和普通的檔案不一樣,比如你想去編輯stdin 檔案。

你會發現它裡面啥都沒用,而且檔案底部提示:“stdin” is not a file



它雖然是以檔案形式呈現出來的,但是是一些特殊檔案。
我們看我們正常的目錄顯示的都是檔案的大小。

而我們的dev,在我們的檔案原來顯示檔案大小的地方顯示兩個數字,左邊叫做主裝置好,右邊叫做次裝置號。大家瞭解一下就可以,在Linux底下,我們會把很多裝置全部抽象成檔案,放在dev底下。

這邊sda是我們的硬碟,sda1是我們硬碟上的第一個分割槽,sda2是我們硬碟上的第二個分割槽,sda5是擴充套件分割槽上的第一個邏輯分割槽。

Linux為什麼要這樣做呢?

因為這樣我們就可以把我們的很多對裝置的操作全部都抽象出來,用一個非常簡單的方式給它統一起來。

Linux 如何往硬盤裡寫資料

我們只需要在硬碟的裝置中像正常寫檔案的方式,寫入一段資料,當你往一個裝置檔案寫入資料的時候,Linux作業系統會根據檔案型別去呼叫這個裝置,比如你往sda裡面寫,它知道這是塊硬碟,它就會呼叫硬碟的裝置驅動,用這個裝置驅動,去向我們實際的裝置裡面把資料寫進去,你看起來是在往一個檔案寫資料,但它會通過裝置驅動把我們的資料寫入到真正的裝置當中去。

同樣,你從一個裝置裡面讀取一段資料的話,它也會通過裝置驅動去我們的裝置裡面把資料讀出來。並且返回給我們。就這麼一個過程。

如果我們往stdout檔案去輸出一個hello,我們的控制檯會回顯一個hello,為什麼呢,因為我們stdout就代表我們控制檯的標準輸出裝置。所以你往我們標準輸出裝置輸出一個字串的話,它就會在我們的螢幕上回顯出來。並且以下這兩段話是沒有區別的,你可以認為他們是一致的,效果是等同的,它其實就是往我們的stdout裡面去寫入一段資料。


在Linux底下所有的東西都是裝置,但這些裝置它是有區別的,我們的裝置它是有型別的。

裝置檔案型別

  • 塊裝置
    如果你學過作業系統,你就會知道硬碟它有一種特性,它是塊裝置,並不是說我往某一個位置寫一個位元組1,我就可以直接寫到硬碟上去。我需要先在記憶體裡面建立一段緩衝區。一個固定大小的緩衝區,比如說這個緩衝區的大小是512位元組。然後我把握想要寫入的資料先寫到這個緩衝區上。然後我在把這512位元組的緩衝區一次性的寫到我的硬碟裝置上。也就是說它一次性寫入,其實要寫512位元組。它會把這512位元組原模原樣的拷貝到我的硬碟的固定位置。而大家都知道硬盤裡面有一個最小的基本單位,這個基本單位叫做扇區。扇區大小就是512位元組,所以一個扇區就是我們可以讀寫的一個單位大小。我們可以在硬碟裝置上讀寫的扇區的大小,被它抽象成一個扇區。
    假設說我要往一個位置寫資料,假設說不做什麼優化,我們第一步,先把資料從固定位置讀到我們的緩衝區,第二步,修改這個緩衝區內部我們需要修改的某些位置資料,第三步,通過硬碟控制器把這個緩衝區寫回去。假如我們不做優化,我們需要這三步。這就是塊裝置的特性,所以塊裝置你會發現我們都需要建立一個緩衝區,我們在把這個緩衝區一次性寫到硬盤裡面去。
    當然,由於緩衝區這個過程是被作業系統遮蔽起來了,這個是作業系統核心的事情。所以我們永遠不會感受到我們是在一塊一塊的寫硬碟,我們永遠感受到的是:比如你打一個字,我們就寫入一個位元組。從我們的感受來說是這個樣子的,對應作業系統來說,它在一塊一塊去寫。
    總結塊裝置:它是支援隨機讀寫的,特點是我們一次性只能去寫入一塊資料或者說讀取一塊資料。

  • 字元裝置
    它的特點是它是一種連續的資料流。它不支援一種隨機的存取,我們一般會按照位元組去讀。
    比如:滑鼠、鍵盤、串列埠、終端,我們只能順序寫入位元組,或順序讀取資料,它就不是用隨機定址了。

  • 網路裝置
    類似於字元裝置,就像剛剛我訪問stdout我們就可以在終端顯示出來,而對於網路裝置,我們必須使用系統呼叫去訪問,這種系統呼叫是什麼呢,就是後面要講的,就是我的套接字

套接字

套接字就是Linux提供給我們的可以訪問網路的一種系統呼叫的抽象封裝。套接字這個東西呢它是由BSE這個系統,它是Unix的一個分支,它發明出來的概念。它是一個對我們上一篇文章講的一些網路協議的封裝。我們上上一篇文章講了一些TCP/IP的參考模型,我們有很多很多協議。而我們平時最關心的兩個協議一個是TCP,一個是IP,比如說我們的傳輸層用的是TCP協議和UDP協議,而我們的網路層使用的是IP協議,這套接字就是對這些協議做一個完整的封裝,它並不是侷限於某一個層次,它並不是侷限於傳輸層或者網路層的這麼一個抽象,它是對整個網路訪問的一個抽象,它既封裝了傳輸層的協議也封裝了網路層的協議。

  • 流套接字(SOCK STREAM)
    它分為三種,一種是使用TCP協議,就是傳輸控制協議,傳輸控制協議它的特點是一個面向連線的可靠資料傳輸服務。
    我們可以保證說A傳給B的資料,B收到資料之後呢它一定是無差錯的,第二個是我的資料在收發資料的時候可能是亂序到達的,TCP協議可以把資料按照它傳送的順序重新整理成完整資料傳給我們。我們只需要關心按照這種位元組流的方式,比如說,A向B寫一個位元組,B就讀幾個位元組,按照這種位元組流的方式順序去讀寫資料就好了,你不用去關心其他而任何問題,你就可以假定說我們的資料是完整的沒有損失的。

  • 資料報套接字(SOCK DGRAM)
    它使用資料報的方式讀寫資料,使用UDP協議,UDP協議它其實是一種最簡單的傳輸層協議,這個協議它只做了埠的一個轉發,它只是把傳輸到某一臺機器的訊息轉到不同的介面。使得不同監聽這個介面的程式可以從不同的埠去讀取資料。它只做一個轉發,除此以外不做任何事情,所以它不確保資料傳輸的可靠性,它資料到達的時候可能是損壞的。它也不能確保說A傳送過來的資料順序和你B接收到的資料順序是一模一樣的。
    那如果你使用UDP協議的時候你想保證你的可靠性和順序性,你就得自己去實現可靠性,實現我們的資料一個保序,使我們得到的資料順序是正確的,這是需要你去實現的。

  • 原始套接字(SOCK RAW)
    這個套接字和上面兩個不一樣,上面兩個都屬於傳輸層的協議封裝,而最後一個它是網路層的協議封裝。假設你非常厲害,你的應用場景非常特殊,你覺得那些傳輸層的協議是不夠用的。你想去自己直接讀取我們的IP資料包,直接去做一些操作,那麼你就要使用這個原始套接字直接去訪問我們IP協議的資料包,直接去做操作,所以說套接字也提供了這麼一種方式。
    一般在沒有什麼特殊情況就很少使用這個東西了,我們一般不會使用它,我們一般也不會使用UDP,UDP一般比如說對流量要求非常高,或者說對系統要求非常高的情況下,我們才會基於UDP去實現我們自己的可靠協議,我們一般來說都會使用TCP協議,包括我們現在使用最多的Http協議,就應用層的協議,它的底層就是基於TCP協議的,所以我們永遠不用擔心我們的資料會有損失,只要我們接收到資料,我們接收到的資料肯定是完整的,是沒有任何錯誤的。

那麼我們如何使用套接字做程式設計呢,接下來我們進入程式碼實踐,看一下在Linux下如何使用Socket進行通訊。

程式設計實現Socket通訊

在Linux 下,建立一個資料夾Socket,在Socket下,建立server.cpp檔案,實現伺服器的編寫。

// standard C++ header files
#include <iostream>
#include <string>
#include <cstdio>
#include <cstdlib>
#include <cerrno> //the header files that handles global errors.

// use socket need contained header files 
#include <unistd.h> //  unix port system  call
#include <sys/types.h> // unix standard types

#include <sys/socket.h>   // socket definition

// network structures and constant definitions
#include <netinet/in.h>
#include <netinet/ip.h>
#include <arpa/inet.h>

int main() {

    // 1.Create socket

    // create socket by socket system call
    /*params
     * AF_INET : base IP protocol to create socket.
     * SOCK_STREAM:stream socket,TCP protocol socket.
     * return : an integer value ,file description symbols.a socket created.
    */
    int sock = socket(AF_INET,SOCK_STREAM,0);

    // check if the socket creation is successful. if success,return >= 0,if -1,create failed.
    if (sock == -1) {

        perror("socket() failed");
        exit(EXIT_FAILURE);
    }

    // use socket to create service.

    // 2.Create net address

    // socket address, sturctures
    sockaddr_in serverAddress;
    // belonging to TCP/IP protocol family.Specify IP address.
    serverAddress.sin_family = AF_INET;
    // Specify  port, local byteCode is converted to network  byteCode. 
    serverAddress.sin_port = htons(4000);
    // IP address is converted to an internal representation.
    serverAddress.sin_addr.s_addr = inet_addr("127.0.0.1");

    // 3.bind the socket to the network address.
    if ( bind(sock,(const sockaddr*)&serverAddress, sizeof(serverAddress)) == -1) {
        perror("bind() failed");
        exit(EXIT_FAILURE);
    }

    // 4.Listen net address

    if( listen(sock, 5) == -1 ) {
        perror("listen() failed");
        exit(EXIT_FAILURE);

    }

    std::cout << "Listen ok" << std::endl;

    // receive net request from client
    sockaddr_in clientAddress;

    socklen_t length = sizeof(clientAddress);
    std::cout << "Wait for client" << std::endl;
    int clientSocket = accept(sock, (struct sockaddr*)&clientAddress, &length);

    if( clientSocket == -1 ) {

        perror("accept() failed");
        exit(EXIT_FAILURE);

    }

    printf("Accept client IP: [(%s)], port: [%d]\n",inet_ntoa(clientAddress.sin_addr), ntohs(clientAddress.sin_port));

    char buffer[8192];
    int recvBytes = recv(clientSocket, buffer,8192,0);
    if ( recvBytes == -1 ) {

        perror("accept() failed");
        exit(EXIT_FAILURE);

    }
    std::string request(buffer,recvBytes);
    std::cout << "Request: \n" << request << std::endl;


    std::string response("200 ok\n\n");
    int sentBytes = send(clientSocket,response.data(), response.size(), 0);
    std::cout << "Sent Bytes:" << sentBytes << std::endl;

    if ( sentBytes == -1 ) {
        perror("send() failed");
        exit(EXIT_FAILURE);
    }

    close(clientSocket);
    close(sock);

    return 0;
}

建立client.cpp檔案,實現客戶端的編寫。

// standard C++ header files
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cerrno> //the header files that handles global errors.

// use socket need contained header files 
#include <unistd.h> //  unix port system  call
#include <sys/types.h> // unix standard types

#include <sys/socket.h>   // socket definition

// network structures and constant definitions
#include <netinet/in.h>
#include <netinet/ip.h>
#include <arpa/inet.h>

int main() {

    // 1.Create socket

    // create socket by socket system call
    /*params
     * AF_INET : base IP protocol to create socket.
     * SOCK_STREAM:stream socket,TCP protocol socket.
     * return : an integer value ,file description symbols.a socket created.
    */
    int sock = socket(AF_INET,SOCK_STREAM,0);

    // check if the socket creation is successful. if success,return >= 0,if -1,create failed.
    if (sock == -1) {

        perror("socket() failed");
        exit(EXIT_FAILURE);
    }

    // use socket to create service.

    // 2.Create net address

    // socket address, sturctures
    sockaddr_in serverAddress;
    // belonging to TCP/IP protocol family.Specify IP address.
    serverAddress.sin_family = AF_INET;
    // Specify  port, local byteCode is converted to network  byteCode. 
    serverAddress.sin_port = htons(4000);
    // IP address is converted to an internal representation.
    serverAddress.sin_addr.s_addr = inet_addr("127.0.0.1");

    // 3.connect the socket to the network address.
    int ret = connect(sock,(struct sockaddr *)&serverAddress, sizeof(serverAddress));

    if ( ret == -1) {
        perror("connect() failed");
        exit(EXIT_FAILURE);
    }

    // send data to server 
    std::string request("Http 1.1\n\n");
    int sentBytes = send(sock,request.data(), request.size(), 0);
    std::cout << "Sent Bytes:" << sentBytes << std::endl;
     if ( sentBytes == -1 ) {
        perror("send() failed");
        exit(EXIT_FAILURE);
    }

    // receiver response from server
    char buffer[8192];

    int recvBytes = recv(sock, buffer,8192,0);
    if ( recvBytes == -1 ) {

        perror("accept() failed");
        exit(EXIT_FAILURE);

    }
    std::string response(buffer,recvBytes);
    std::cout << "Request: \n" << response << std::endl;

    return 0;
}

寫一個Makefile編譯檔案

all: server client

clean:
    rm * server client

server: server.o
    g++ server.o -o server

server.o: server.cpp
    g++ server.cpp -o server.o -c -std=c++11

client: client.o
    g++ client.o -o client

client.o: client.cpp
    g++ client.cpp -o client.o -c -std=c++11

使用make命令編譯檔案

我們需要同時開啟兩個終端,一個操作伺服器,一個操作客戶端。

  1. 執行伺服器,監聽埠,等待客戶端請求。
  2. 此時開啟另一個客戶端,執行,向伺服器傳送請求,可以看到伺服器對客戶端請求的響應,客戶端接收到10個位元組,內容為:200 ok
  3. 而這是再看伺服器端,接收到了客戶端的請求,客戶端地址為:127.0.0.1,埠號:35298,請求內容為Http 1.1 位元組為:8

網路位元組序問題

在server.cpp和client.cpp中,都要如下程式碼,作用是將本地位元組序轉換成網路位元組序。

...
 // Specify  port, local byteCode is converted to network  byteCode. 
    serverAddress.sin_port = htons(4000);
...

不同處理器的端序是不一樣的,例如:
Intel的端序為小端;
ARMLinux 即支援大端也支援小段。
為保證資料傳輸一致,他們在網路傳輸的時候都需要轉換成大端表示。所以會有一個網路位元組序轉化問題,這個你是需要設定的。

對服務端程式碼的抽象封裝

一般我們為了程式碼的整潔和擴充套件性,會把通用程式碼塊封裝起來,接下來我們對server.cpp的程式碼進行一次封裝,建立Socket.h檔案,來封裝socket的建立和連線過程。
Socket.h:

// C++ new grammar ,prevent header files from being repeated.
#pragma once

// standard C++ header files
#include <iostream>
#include <cstdio>
#include <cstdlib>

// use socket need contained header files 
#include <unistd.h> //  unix port system  call
#include <sys/types.h> // unix standard types

#include <sys/socket.h>   // socket definition

// network structures and constant definitions
#include <netinet/in.h>
#include <netinet/ip.h>
#include <arpa/inet.h>

// 1. how to definition socket
using SOCKET = int32_t;

class Socket {

public:
    Socket() {
        _socket = socket(AF_INET, SOCK_STREAM, 0);

         if (_socket == -1) {
            perror("socket() failed");
            exit(EXIT_FAILURE);
        }

    }
    Socket(SOCKET socket) : _socket(socket) {}

    virtual ~Socket() {

        close(_socket);
    }

    int32_t Send(const char * buf, size_t size) {

        int32_t sentBytes = send(_socket, buf, size, 0);

        if( sentBytes == -1) {

            perror("send() failed");
            exit(EXIT_FAILURE);
        }

        return sentBytes;
    }

    int32_t Receive(char* buf, size_t size) {

        int32_t recvBytes = recv(_socket, buf, size,0);

        if( recvBytes == -1) {

            perror("recv() failed");
            exit(EXIT_FAILURE);
        }

        return recvBytes;
    }

    protected:
        SOCKET _socket;

};

using TcpConnection = Socket;

class TcpServer: public Socket {

public: 
    TcpServer() {}

    void Listen(const std::string& host, int32_t port)  {
            // socket address, sturctures
        sockaddr_in serverAddress;
        // belonging to TCP/IP protocol family.Specify IP address.
        serverAddress.sin_family = AF_INET;
        // Specify  port, local byteCode is converted to network  byteCode. 
        serverAddress.sin_port = htons(port);
        // IP address is converted to an internal representation.
        serverAddress.sin_addr.s_addr = inet_addr(host.c_str());

        // 3.bind the socket to the network address.
        if ( bind(_socket,(const sockaddr*)&serverAddress, sizeof(serverAddress)) == -1) {
            perror("bind() failed");
            exit(EXIT_FAILURE);
        }

        // 4.Listen net address

        if( listen(_socket, 5) == -1 ) {
            perror("listen() failed");
            exit(EXIT_FAILURE);

            }
    }

    TcpConnection* Accept() {

            // receive net request from client
        sockaddr_in clientAddress;

        socklen_t length = sizeof(clientAddress);
        std::cout << "Wait for client" << std::endl;
        int clientSocket = accept(_socket, (struct sockaddr*)&clientAddress, &length);

        if( clientSocket == -1 ) {

            perror("accept() failed");
            exit(EXIT_FAILURE);

        }

        printf("Accept client IP: [(%s)], port: [%d]\n",inet_ntoa(clientAddress.sin_addr), ntohs(clientAddress.sin_port));

        return new TcpConnection(clientSocket);
    }
};

重構server.cpp 程式碼:

// standard C++ header files
#include "Socket.h"

#include <string>
#include <cerrno> //the header files that handles global errors.


int main() {
    // Create server
    TcpServer server;
    // Listen port
    server.Listen("127.0.0.1",4000);

    while( true ) {
        // Accet connection
        TcpConnection* connection = server.Accept();

        // Read data
        char buffer[8192];
        int recvBytes = connection->Receive(buffer,8192);
        std::string request(buffer,recvBytes);
        std::cout << "Request: \n" << request << std::endl;

        // Send data
        std::string response("200 ok\n\n");
        int sentBytes = connection->Send(response.data(), response.size());
        std::cout << "Sent Bytes:" << sentBytes << std::endl;

        if ( sentBytes == -1 ) {
            perror("send() failed");
            exit(EXIT_FAILURE);
        }

        delete connection; 

    }


    return 0;
}

再次編譯執行後,我們看到伺服器不是接收完一個資訊就停止了,而是等待下一次請求,所以我們在server.cpp檔案中,將監聽埠的程式碼放在While(true) {}中,這樣伺服器在處理完一個情況後,會繼續監聽另一個,而不是失去連線了。

還有一個問題是,我們在Accept()接收一個請求後,無法在處理其他請求,而我們現實中請求是併發的,這樣我們是無法容忍的,所以我們需要有非同步或多執行緒的技術,這個需要後續優化。

C++ 11 編碼

ASCII

ASCII:是美國標準資訊交換碼(American Standard Code for Information InterChange),這是一個編碼方案,我們計算機裡面存的都是數字,我們要讓計算機表示字元的話,我們就必須使用數字去表示每個字元。而且我們的表示不能重複,剛開始這個字元是美國的,所以美國人給它定義了一個標準的編碼方案,叫ASCII。

編碼方案

  • ISO-8859-*
    這邊一共有128個字元,使用的是低7位。也就說它的範圍是從0x00~0x7f,沒有使用8位,也沒有使用一個完整的位元組,這種對應美國來說英語已經夠了,完全沒問題的。後來美國人把電腦賣到全世界,比如歐洲,雖然他們也用的字母,但是跟美國的不一樣,所以,他們自己也需要做一個編碼,那怎麼辦呢。ISO就提出了一個標準,叫ISO-8859-*,它後面的星號代表說它是一組協議而不是一個協議。當我們計算機剛到歐洲的時候,ISO提出了一個ISO-9959 - 1,它是最早的一個標準,而到現在已經到了ISO-8859-16.這些不同的編碼呢只是為了適應不同的國家的字元的不同。
    利用剩餘的128個字元,0x80 - 0xff,如果ISO-8859-1裡面存了很多法文,那麼德文我就存不下了,那麼它怎麼做呢,因為我們的ASCII碼它只用7位,我就把我們的最高位用起來。在ISO-8859-16裡面我就從0x80 - 0xff為止也是128個字元。那我根據不同國家他們的不同情況呢,去建立它們的對應關係。那就會出現一個問題,比如我寫的檔案是法文的,我把我的檔案放在德文編碼的電腦上去,就很容易出現問題。比如在我的ISO-8895-1裡面字元0xf1字元可能表示x,那麼在我的ISO-8859-16這個字元可能是y,比如我在現在在ISO-8859-16的編碼裡面寫了y,而到ISO-8859-1裡面就變成了x,這肯定是無法容忍的。所以我們必須得想辦法處理一下這個問題,我們後面再講一下這個問題。
  • 國標:GB2312/GBK,GB18030
    第二個是我們的國標,歐洲有很多字元,這些字元都可以放到128個格子裡面去,因為他們的字母不可能超過128個嗎,但中國的漢字博大精深,數量非常多,我們不可能只有128個漢字呀,那怎麼辦呢,我們國家就指定了GB2312:中國國家標準,雙位元組編碼,如果有一個位元組的話,就等同於ASCII,如果有兩個位元組,就是漢字等符號。但他們都可以向下相容,這是一個非常好的特性,便於我們軟體向下移植。但是GB2312它有個問題,它收錄的字太少了,大概只收錄了六七千字,大概涵蓋我們日常生活90%以上的單詞。但是很多字沒有,所以後來中國人又提出了新的國標GBK,K是擴充套件的意思,它有2萬多字,那我們最新標準是GB18030,它是GB2312的超集,它是向下相容的,它也是最新標準,它採用變長編碼,它裡面存了大約7萬字。比如我是雙位元組編碼,大約最多隻能有2的16次方這麼多字,65536個字,那我7萬多字怎麼存下來,它是一個變長編碼,如果是一位元組的話,它會向下相容ASCII,如果是兩位元組的話呢,它會向下相容GB2312/GBK,如果我實在是字塞不下了,我就用四個位元組來表示,只不過它會用一些特殊的頭部,來標識它是四位元組的一個字元,但這隻解決我們國家的問題。現在問題來了,不同國家它都需要解決位元組的字元問題,這樣問題就來了,我中國人,到日本去,我用的是GBK,它用的不是,這樣就很容易出現編碼錯亂的問題,這就是所謂的亂碼。就比如早期你玩遊戲,很多臺灣遊戲用的是繁體中文,繁體中文用的格式是BIG5,俗稱大5碼,你玩遊戲,打開發現是亂碼,為什麼呢,因為我們系統編碼是GB,但是它遊戲裡面內容的編碼是大五碼,這樣的話大五碼對應的GB的字元呢,它肯定就是亂碼了。所以當時有很多軟體來幫我們解決這個問題,比如最有名的是南極星這個軟體。解決編碼問題,實際上就是做一個編碼的處理而已。這就是之前的一些解決方案。

但是後來又出問題了,如果說我希望我的檔案在全世界各地傳都是沒有問題的,那該怎麼辦呢?

  • Unicode
    因為我們ISO提出了一個新的標準,叫做Unicode。它是一個通用程式碼編碼標準。它的思路是你的ASCII碼和ISO-559-1用了一位元組去做編碼,那我是不是你一位元組不夠用兩位元組嗎。所以呢,他就指定了一堆的CodePoint,一個程式碼點呢就對應了一個具體的字元。在Unicode裡面,最初的程式碼點它是16位的。也就是說他最多可以支援65536個字。所以他們就設計了一種具體的編碼方案的實現。因為Unicode分為2個,一部分是它的編碼標準,標準定義了我才能CodePoint,去對應那些字元。具體實現呢,是一個我在計算機裡面具體儲存的實現。它和我們的標準Unicode有一個對映關係。

  • UCS-2
    最早的實現是UCS-2:是一種固定編碼方法,它的特點是不管怎樣,我把所有字元都變成2個位元組。如果你用A的話,A的編碼是65,那麼如果用UCS-2的話,他就強制變成了2265,它的思路就是假定我65536個數字已經存下所有的字元了,但是呢,理想很豐滿,現實很骨感的,馬上就打臉了,大家想想看,光我GB18030裡面就有7萬個字元,更別說加上其他的語言。所以說UCS-2馬上就出現了問題。大家就轉而尋找其他的解決方案。

  • UTF-16
    其中有一個解決方案就是UTF-16,它相當於UCS-2的一個超集,它是以UCS-2為基礎的一個超集。它是一個變長編碼方案。你現在不是16位存不下嗎,那你能用16位存下的我就用16位存,預設是兩個位元組,如果你這個東西存不下了,我就再擴兩個位元組,我用4個位元組來儲存。這樣的話呢,相當於我UCS-2做了加強,我新的字元就是超出我65536以外的一個字元了,我就把它擴充套件成4個位元組,這樣來做處理,這樣的話我就可以存下新來的所有字元了,但是這樣的編碼方案它有一個非常大的問題是我無論什麼字元至少都需要有兩個位元組去編碼,比如我們平時寫程式碼,英文寫的肯定比中文多,那麼英文那些字母不都是單一位元組的嗎,那如果你用UTF-16這種編碼實現去儲存那個檔案,那你就發現這個檔案突然變得是原來的兩個大,這樣就得不償失了。所以在儲存上我們一般不會使用UTF-16,我們一般會使用UTF-8.

  • UTF-8
    UTF-8它就是解決UTF-16和UCS-2不相容ASCII碼的問題。它是一個非常自由的編碼方案,它的長度的變化是非常自由的,它有一位元組,就相當於我們的ASCII或ISO-8859-1,那同時呢還有兩個位元組的編碼,如果2個位元組存不下呢,它還有3位元組,3位元組存不下呢,它還有4位元組,它是一個變長編碼方案,它總是能把我們多位元組的程式碼對映到我們Unicode的程式碼點上。它是一個編碼的實現。這種優點呢它是相容我們的ASCII,它往往能夠保證我們生成的檔案大小往往是比較小的。如果你使用UTF-16,那基本上整體的容量都要擴一倍。UTF-8是我們現在使用最多的一個協議。

  • UTF-32/UCS-4
    最後一個是UTF-32(也就是UCS-4),UCS-4指的是它是由4個位元組組成的,所以他們是大體相同的一個協議。它的意思是我是一個定長編碼方法。所有的元素都是用4個位元組來表示,它的優點是我所有的字元都可以顯示,缺點就是它實在是太大了,你想想看,你的程式碼全是英文,那你用UTF-32或者UCS-4儲存的話,它會瞬間使你的檔案可以變成原來的四倍,這樣是非常沒有價效比的,所有它並沒有得到非常廣泛的應用。

我們現在使用比較多的,國內自己的話一般會使用GB系列。還有Unicode,內部儲存的時候是UTF-16,如果寫到硬碟上會使用UTF-8,我傳輸的時候我也會採用UTF-8,那這個大家要注意一下。

那麼C++ 11對我的編碼提供什麼支援呢

C ++ 11 編碼支援

  • 字元
    C++ 11 裡面加入了非常多的字元型別,首先第一個型別是在C++98裡面就有的型別叫做wchar_t,它是一個寬字元型別,為什麼需要有寬字元呢,因為大家知道在C++裡面字元都是一個位元組的,那我是存不下那些以2位元組或4位元組編碼的字元的,所以C++就提出了這麼一種資料型別,它允許你長度大於我們的字元型別,比如在Windows底下,wchar_t它的長度就是2,在Linux底下它的長度就是4,它就導致了一個問題,就是如果你想編寫一個可移植性很強的程式,你就會發現這個東西它不可移植,因為它的位元組數不同平臺都不一樣,因為它標準沒有規定這個問題,所以一般在可移植程式裡面一般都很少使用這種型別,如果你在這個字元前面加上一個L他就會把這個字元轉成wchar_t型別。
    第二種字元是在我的字元前面加上一個小寫的u,小寫的u表示他是一個UTF-16編碼,這就有個問題,你有的字他可能需要一個16位的就行,有的字他可能需要兩個16位,這個時候如果你在單引號裡面使用Unicode字元的話,他就報錯,因為這個字元前面限定了是一個16位的字元,所以說你那個東西如果說需要兩個16位才能儲存下的話,那麼他就報錯。
    下面這個大寫U,它是UTF-32的一個方案,它會使用UTF-32進行儲存。
    最後一個是非常重要的一個概念,它是把這個字串轉成UTF-8的字串,大家知道UTF-8它是一個非常靈活的可變字元編碼方案,所以說其實他的編碼是以一個位元組一個位元組的為一個單位的。它可以按位元組儲存,所以我們就沒必要為他去單獨定義一個型別,我們一般就只用char來儲存它。這邊也有一個問題,就是一個char它只有8位,如果你使用的是一箇中文他就存不下,它就報錯,那我應該怎麼做呢,我更保險的是存成字串。

  • 字串
    比如說你在字串前加L,你就把他變成wstring,它的元素裡面每一個型別都是一個wchar_t,
    如果你在字串前面加一個小寫的u呢,他就形成了一個u16型別的字串u16string,它也是一個字串,只不過元素型別是char16_t,
    u32string也是一樣的,
    最後是一個u8,u8它返回的型別應該是string,為什麼呢,剛剛也講過,因為我的UTF-8它是一個位元組流,他是一個單位元組流的這麼一個編碼方案。雖然你可以把幾個位元組放在一起,但是我們日常看的時候,它是單個位元組的組合的位元組流,而且它可以完美向下相容我們原有的編碼方案。所以說大家要注意這些東西。

然後呢,大家要注意,在C++ 11裡面它提供了一個庫,這個庫叫做codecvt,非常遺憾的是這個庫在C++ 17裡面廢棄了,所以說千萬不要再去使用它。為什麼廢棄呢,因為這更東西實在是太難用了,使得有人覺得它甚至是一個設計失誤,所以大家就知道在C++ 17裡面知道怎麼去定義你需要的字元型別,那怎麼轉換呢,原來的標準庫已經不給你用了,因為他覺得它不好,我們可以用的是一些第三方編碼庫,比如說iconv,這個是用的非常廣的一個編碼庫,大家有興趣可以去了解一下。

題外話:Windows與編碼

講完C++的編碼問題,我們就來看一個體外話,這個題外話就是關於windows的一個編碼問題,大家知道window是一個歷史很長的系統,如果再算上它的前輩DOS,歷史是非常長的。

Dos
  • ASCII
    大家都知道window它有一個特性,它和Intel一樣,它有一個非常強烈的向下相容的傾向, 比如說你十年前寫一個程式,在現在的系統上很有可能還是能夠正常執行的,這是非常了不起,但這就會導致我們很多設計方案非常非常複雜,比如說編碼問題,而且編碼問題是非常複雜的問題,在一開始DOS時代,美國人使用的,它就直接使用ASCII碼就好了,這個是沒有任何問題的。但是當它把DOS賣給其他國家的人的時候,它就得支援國際化,支援多國語言,它剛好賣的那些國家都是西歐國家,英法德等,但是他們的編碼方案可能不一樣,比如德國可能是ISO-8859 -1,法國可能是ISO-8859-16,兩者是不一樣的呀,那如果你在德國的機器上寫一個檔案,在法國機器上看肯定是亂碼,那我們應該怎麼辦呢。

  • CodePage
    所以說DOS找了一個概念叫內碼表CodePage,
    內碼表的思路:
    當時ISO的系列編碼思路都是這樣的,我們也講過,低128位的字元它都是一個ASCII的字元,高128位的字元它是一個自定義的字元,就是不同的標準我可以自己去定義這128個字元,那我就解決這個問題了,我所有低128位字元我就用ASCII,高128位字元我給每一個編碼方案給它命名一個程式碼,比如ISO-8859-1我給它一個程式碼450,那ISO-8859-16它的程式碼是480,如果你想使用ISO-8859-1,你就把你的CodePage設定成450,它就會自動的將我們高128位字元按照450這種編碼規則去顯示。如果你把CodePage設定成480,他就按照480對應的規則去顯示,這種我們可以根據我的內碼表去調整我的系統裡面文字的變化,這樣他就支援了多國語言,看起來非常聰明,但是這個東西拿到中國的時候他就我沒用了,因為我們發現漢字實在太多了,128個字元根本就存不下,不過呢,他們還是給中文定義了中文內碼表,簡體中文程式碼是936,這128個字元裡面主要是定義了一些符號,那我除了這128個字元以外的東西怎麼辦?中國人就想了很多方案,首先我們出了自己的國標GB系列,當時他們又做了很多中文的DOS,比如CCDOS,比如說UCDOS,他們的賣點就是支援在DOS介面上顯示中文,支援中文輸入法等等,所以當時這都是非常重要的產品,如果你在那個年代工作過,你應該對這些東西印象非常深刻。

Windows
  • 核心:Unicode
    那麼後來呢,從DOS時代,我們就進入了windows時代,在早起windows時代,3.0、3.1它都是跑在DOS上的,所以它當時的編碼方案並沒有做什麼太大調整。但是它在後來,在我們的後期windows版本里面,它把核心裡面的所有字元全部都是以Unicode編碼,也就是說無論你的檔案外面是什麼編碼,它的核心裡面去做處理的時候永遠是Unicode編碼,但是他們一開始用的是UCS-2,後來又變成UTF-16,它主要用的是UTF-16,但是假如我強制外部也全部使用Unicode,這就出現一個問題了,我以前開發的那些軟體都不能使用了,因為我以前開發的軟體都沒用使用Unicode,同時我以前那些文件也沒有Unicode,那這樣的話對使用者來說是非常不友好的,至少對以前的老使用者來說是非常不友好的,所以微軟用老套路,需要向下相容,它怎麼向下相容呢,它又把內碼表這個概念套到window上面去了。

  • 外部:ANSI-CodePage/OEM-CodePage
    它外部呢每個語言都有自己獨立的內碼表,這個內碼表呢它會指導我們去使用每個國家自己的編碼,比如說如果你用中文的話,它就會使用GBK,GB2312,GB18030這些標準,這樣外部檔案使用自己國家的編碼方案,但是,它的系統核心裡面全部使用Unicode,也就是說它的內外是不一致的,這樣就會變得非常複雜了。

  • CRT:Unicode/MBCS/SBCS
    比如說,window現在的開發它有個東西叫CRT,它根據它的程式內部的編碼方案一共分成3個型別,一個是Unicode,Unicode就是你的程式內部全部使用Unicode,你不用其他編碼,這樣的話你無論在什麼系統上都是正常實現的,這是毋庸置疑的。但是它有一問題,就是我的老程式怎麼辦,老程式就用其他的編碼方案來執行,比如MBCS,多位元組的一個字符集,比如SBCS單位元組的一個字符集。這樣的話它每個東西都有自己的獨立執行庫,這樣使得我們在window底下處理字元編碼就會變得非常複雜,並不是所有的軟體都用Unicode,有些軟體有些庫都還使用了比如JM,那如果你結合動態編譯,靜態編譯來搞得話,這就會變得非常複雜,所以在window底下他就會有一些相應的變成慣例,window程式設計的相關書籍,都會詳細介紹window底下Unicode一個解決方案。當然,這樣就使得我們在日常編寫程式碼的時候會變的非常複雜。

  • 關鍵:Locale
    那我們系統是怎麼判定我們的外部編碼呢?大家記得在我們的控制欄裡面我們可以設定什麼呢,設定我們的區域。比如你設定成中國,它就會把我們的系統內碼表也設定成中國內碼表。每個區域它都要一個唯一ID叫做LCID,比如中國大陸的LCID是2052,每個LC它都會關聯一定的CodePage,一個ANSI-CodePage和一個OEM-CodePage。這樣的話呢我一切換區域就可以把我的系統的語言切換成對應的語言,系統的編碼切換成對應的編碼,系統的其他各種貨幣格式、時間格式也都切換成對應格式。這就是Locale的作用。