C++對象封裝後的記憶體佈局

来源:https://www.cnblogs.com/isharetech/p/18135696
-Advertisement-
Play Games

C++語言相比C語言最重要的功能就是支持面向對象編程,為了實現面向對象編程,C++增加了類的封裝和多態、繼承等特性,那麼這些特性的加入是否會造成對象的記憶體成本增加?如果增加了,那麼到底增加了多少? ...


在C語言中,數據和數據的處理操作(函數)是分開聲明的,在語言層面並沒有支持數據和函數的內在關聯性,我們稱之為過程式編程範式或者程式性編程範式。C++相容了C語言,當然也支持這種編程範式。但C++更主要的特點在支持基於對象(object-based, OB)和麵向對象(object-oriented, OO),OB和OO的基礎是對象封裝,所謂封裝就是將數據和數據的操作(函數)組織在一起,在語言層面保證了數據的訪問和操作的一致性,這樣從代碼上更能表現出數據和函數的關係。在這裡先不討論在軟體工程上這幾種編程範式的優劣,我們先來分析對象加上封裝後的記憶體佈局,C++相對於C語言是否需要占用更多的記憶體空間,如果有,那麼到底增加了多少記憶體成本?本文接下來將對各種情形進行分析。

空對象的記憶體佈局

請看下麵的代碼,你覺得答案應該輸出多少?

#include <iostream>
using namespace std;

class Object {
    // empty
};

int main() {
    Object object;
    cout << "The size of object is: " << sizeof(object) << endl;

    return 0;
}

答案是會輸出:The size of object is: 1,是的,答案是1位元組。在C++中,即使是空對象也會占用一定的空間,通常是1個位元組。這個位元組用來確保每個對象都有唯一的地址,以便在程式中進行操作。

含有數據成員的對象的記憶體佈局

  • 非靜態數據成員

現在再往這個類裡面加入一些非靜態的數據成員,來看看加入非靜態的數據成員之後記憶體佈局占用多少空間。

#include <iostream>
using namespace std;

class Object {
public:
    int a;
    int b;
};

int main() {
    Object object;
    cout << "The size of object is: " << sizeof(object) << endl;
    cout << "The address of object: " << &object << endl;
    cout << "The address of object.a: " << &object.a << endl;
    cout << "The address of object.b: " << &object.b << endl;

    return 0;
}

運行結果輸出的是:

The size of object is: 8
The address of object: 0x16f07f464
The address of object.a: 0x16f07f464
The address of object.b: 0x16f07f468

現在object對象總共占用了8位元組。int類型在我測試的機器上占用4位元組的空間,這個跟測試的機器有關,有的機器有可能是8位元組,在一些很老的機器上也有可能是2位元組。

看後面三行的地址,可以看出,數據成員a的地址跟對象的地址是一樣的,也就是說它是排列在對象的開始處,接下來是隔了4個位元組後的地址,也就是數據成員b的地址,這說明數據成員a和b是順序且緊密排列在一起的,並且是從對象的起始處開始的。結果表明,在這種情況下,C++的對象的記憶體佈局跟C語言的結構的記憶體佈局是一樣的,並不會比C語言多占用一些記憶體空間。

  • 靜態數據成員

C++的類也支持在類裡面定義靜態數據成員,那麼定義了靜態數據成員之後類對象的記憶體佈局是怎麼樣的呢?在上面的類中加入一個靜態數據成員,如以下代碼:

class Object {
public:
    int a;
    int b;
    static int static_a;
};

運行結果輸出:

The size of object is: 8
The address of object: 0x16b25f464
The address of object.a: 0x16b25f464
The address of object.b: 0x16b25f468
The address of object.static_a: 0x104ba8000

對象的大小結果還是8位元組,說明靜態成員變數並不會增加對象的記憶體占用空間。看下它們各個的地址,從結果可以看出,靜態成員變數的地址跟非靜態成員變數的地址相差很大,推斷肯定不是和它們排列在一起的。在main函數中增加如下代碼:

Object obj2;
cout << "The size of obj2 is: " << sizeof(obj2) << endl;
cout << "The address of obj2.static_a: " << &obj2.static_a << endl;

輸出結果為:

The size of obj2 is: 8
The address of obj2.static_a: 0x104ba8000

定義了第2個對象,這個對象的大小也還是8位元組,說明靜態對象不是存儲在每個對象中的,而是存在某個地方,由所有的同一個的類對象所共有的。從第2行輸出的地址可以看出來,它的地址和第1個對象輸出的地址是一樣的,說明它們指向的是同一個變數。其實類中的靜態數據成員是和全局變數一樣存放在數據段中的,它的地址是在編譯的時候就已經確定的了,每次運行都是一樣的。它和全局變數一樣,地址在編譯時確定,所以訪問它沒有任何性能損失,和全局變數的區別是它的作用域不一樣,類的靜態數據成員的作用域只有在類中可見,訪問許可權受它在類中定義時的訪問許可權區段所控制。

含有成員函數的對象的記憶體佈局

上面所討論的都是類裡面只有數據成員的情況,如果在類里再加上成員函數時,類對象的記憶體佈局會有什麼變化?在類中增加一個public的成員函數和一個靜態成員函數,代碼修改如下:

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

class Object {
public:
    void print() {
        cout << "The address of a: " << &a << endl;
        cout << "The address of b: " << &b << endl;
        cout << "The address of static_a: " << &static_a << endl;
    }

    static void static_func() {
        cout << "This is a static member function.\n";
    }

private:
    int a;
    int b;
    static int static_a;
};

int Object::static_a = 1;

int main() {
    Object object;
    cout << "The size of object is: " << sizeof(object) << endl;
    printf("The address of print: %p\n", &Object::print);
    printf("The address of static_func: %p\n", &Object::static_func);
    object.print();
    object.static_func();

    return 0;
}

運行輸出結果如下:

The size of object is: 8
The address of print: 0x102d93120
The address of static_func: 0x102d931c4
The address of a: 0x16d06f464
The address of b: 0x16d06f468
The address of static_a: 0x102d98000
This is a static member function.

類對象的大小還是沒變,還是8位元組。說明增加成員函數並沒有增加類對象的記憶體占用,無論是普通成員函數還是靜態成員函數都一樣。其實類中的成員函數並不存儲在每個類對象中的,而是跟類的定義相關的,它是存放在可執行二進位文件中的代碼段里的,由同一個類所產生出來的所有對象所共用。從上面輸出結果中兩個函數的地址來看,它們的地址很相近,說明普通成員函數和靜態成員函數都是一樣的,都存放在代碼段中,地址在編譯時就已確定。調用它們跟調用一個普通的函數沒有什麼區別,不會有性能上的損失。

含有虛函數的對象的記憶體佈局

面向對象主要的特征之一就是多態,而多態的基礎就是支持虛函數的機制。那麼虛函數的支持對對象的記憶體佈局會產生什麼影響呢?這裡先不分析虛函數的實現機制,我們先來分析記憶體佈局的成本。在上面的例子中加入兩個虛函數:一個普通的虛函數和虛析構函數,代碼如下:

virtual ~Object() {
    cout << "Destructor...\n";
}

virtual void virtual_func() {
    cout << "Call virtual_func\n";
}

// 在main函數里增加兩行列印
printf("The address of object: %p\n", &object);
printf("The address of virtual_func: %p\n", &Object::virtual_func);

編譯運行,看看輸出:

The size of object is: 16
The address of object: 0x16f97f458
The address of print: 0x100482f74
The address of static_func: 0x10048301c
The address of virtual_func: 0x10
The address of a: 0x16f97f460
The address of b: 0x16f97f464
The address of static_a: 0x100488000
Destructor...

在沒有增加任何數據成員的情況下,對象的大小增加到了16位元組,這說明虛函數的加入改變了對象的記憶體佈局。那麼增加的內容是什麼呢?我們看到輸出的列印中對象的首地址為0x16f97f458,而數據成員a的地址為0x16f97f460,這中間剛好差了8位元組。而從上面的分析我們知道,原來a的地址是和對象的首地址是一樣的,也就是說對象的記憶體佈局是從a開始排列的,而現在在對象的起始地址和成員變數a之間空了8個位元組,那麼排在a之前的這8個位元組的內容是什麼呢?我們加點代碼把它的內容輸出出來,在main函數中加入以下代碼:

long* p =  (long*)&object;
long* vptr = (long*)*p;
printf("vptr is %p\n", vptr);

輸出結果:

The size of object is: 16
The address of object: 0x16b00f458
The address of print: 0x104df2f68
The address of static_func: 0x104df3010
The address of virtual_func: 0x10
The address of a: 0x16b00f460
The address of b: 0x16b00f464
The address of static_a: 0x104df8000
vptr is 0x104df4110
Destructor...

它的內容是0x104df4110,它其實是一個指針,在我的機器上占用8位元組,在某些機器上可能是4位元組。這個指針指向的其實是一個虛函數表,虛函數表是一個表格,表格裡的每一項的內容存放的是每個虛函數的地址,這個地址指向虛函數真正的地址,在上面的列印中虛函數列印出來的地址是0x10,這個其實不是它的真正地址,是它在表格中的偏移地址。可以看到這個虛函數表地址和靜態成員static_a的地址非常相近,其實虛函數表也是存放在數據段裡面的,它在編譯的時候由編譯器確定好內容,並且編譯器會自動擴充一些代碼,在構造對象的時候把虛函數表的首地址插入到對象的起始位置。虛函數的詳細分析在這裡先不展開,後面再詳細分析。從這裡的分析可以看到,類裡面增加虛函數,會在對象的起始位置上插入一個指針,對象的大小會增加一個指針的大小,為8位元組或者4位元組。如下麵的示意圖:
image

繼承體系下的對象的記憶體佈局

繼承是C++中很重要的一個功能,按照不同的形式有單一繼承、多重繼承、虛繼承,按照繼承許可權有public、protected、private。下麵我們一一來分析,為簡單起見,我們只分析public繼承。

  • 單一繼承
#include <iostream>
#include <cstdio>
using namespace std;

class point2d {
public:
    int x() { return x_; }
    int y() { return y_; }
protected:
    int x_;
    int y_;
};

class point3d: public point2d {
public:
    int z() { return z_; }

    void print() {
        printf("The address of x: %p\n", &x_);
        printf("The address of y: %p\n", &y_);
        printf("The address of z: %p\n", &z_);
    }
protected:
    int z_;
};

int main() {
    point2d p2d;
    point3d p3d;
    cout << "The size of p2d is: " << sizeof(p2d) << endl;
    cout << "The size of p3d is: " << sizeof(p3d) << endl;
    cout << "The address of p3d: " << &p3d << endl;
    p3d.print();

    return 0;
}

上面的代碼編譯運行輸出:

The size of p2d is: 8
The size of p3d is: 12
The address of p3d: 0x16d2bb458
The address of x: 0x16d2bb458
The address of y: 0x16d2bb45c
The address of z: 0x16d2bb460

類point3d只有一個數據成員z_,但大小卻有12位元組,很明顯它的大小是加上父類point2d的大小8位元組的。從輸出的地址看,p3d的地址是0x16d2bb458,從父類繼承而來的x_的地址也是0x16d2bb458,這說明從父類繼承而來的數據成員排列在前面,從對象的首地址開始,按照它們在類中的聲明順序依次排序,接著是子類自己的數據成員,從上面的結果看起來對象中的數據成員在記憶體中是按照順序且緊湊的排列在一起的,如下圖所示:
image
我們再來驗證一下,把數據成員的聲明類型改為char型,修改後輸出結果:

The size of p2d is: 2
The size of p3d is: 3
The address of p3d: 0x16ba63467
The address of x: 0x16ba63467
The address of y: 0x16ba63468
The address of z: 0x16ba63469

看起來似乎我們的猜測是正確的,我們再繼續修改,把x_改為int型,其它兩個為char型,聲明順序還是跟之前一樣,這次的輸出結果:

The size of p2d is: 8
The size of p3d is: 12
The address of p3d: 0x16d033458
The address of x: 0x16d033458
The address of y: 0x16d03345c
The address of z: 0x16d033460

這次跟我們想要的結果不一樣了,p2d的大小不是5位元組而是8位元組,p3d的大小不是6位元組而是12位元組,看起來編譯器填充了記憶體空間使得他們的大小變大了。其實這時編譯器為了訪問效率選擇了對齊,為了讓變數的地址是4的倍數,它會填充中間的空擋,這些行為跟編譯器有很大的關係,不同的編譯器有不同的行為,類中數據成員的不同聲明順序和不同的數據類型可能就導致不同的結果。佈局示意圖如下:
image

  • 多重繼承

接下來看看一個類繼承了多個父類,它的記憶體佈局是怎麼樣的。請看下麵的代碼:

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

class Base1 {
public:
    int b1;
};

class Base2 {
public:
    int b2;
};

class Derived: public Base1, public Base2 {
public:
    int d;
    void print() {
        printf("The address of b1: %p\n", &b1);
        printf("The address of b2: %p\n", &b2);
        printf("The address of d: %p\n", &d);
    }
};

int main() {
    Derived obj;
    printf("The size of obj is: %lu\n", sizeof(obj));
    printf("The address of obj: %p\n", &obj);
    obj.print();

    return 0;
}

輸出結果:

The size of obj is: 12
The address of obj: 0x16f737460
The address of b1: 0x16f737460
The address of b2: 0x16f737464
The address of d: 0x16f737468

對象的總大小是12位元組,它是子類自身擁有的一個數據成員4位元組加上分別從兩個父類繼承而來的兩個數據成員共8位元組的總和。從輸出的地址可以看出來,從父類Base1繼承來的成員b1和對象的首地址相同,接著是從父類Base2繼承而來b2,最後是子類自己的成員d,說明對象的佈局是從b1開始,然後是b2,最後是d,這個跟繼承的順序有關,第一繼承而來的數據成員排在最前面,按照在類中聲明的順序依次排列,其次是第二繼承而來的數據成員,以此類推,最後是子類自己的數據成員。佈局示意圖如下:
image

  • 父類帶虛函數的繼承

如果父類中帶有虛函數,那麼對子類的記憶體佈局有何影響?在上面的代碼中的兩個父類各加上一個虛函數,而子類暫時先不加虛函數,如下代碼:

// 在class Base1中加入以下代碼
virtual void virtual_func1() {
    printf("This is virtual_func1\n");
}

// 在class Base2中加入以下代碼
virtual void virtual_func2() {
    printf("This is virtual_func2\n");
}

編譯運行,輸出結果:

The size of obj is: 32
The address of obj: 0x16b807448
The address of b1: 0x16b807450
The address of b2: 0x16b807460
The address of d: 0x16b807464

這次對象的大小竟然是32位元組,比上面的例子增加了20位元組,這裡並沒有增加任何數據成員,只是僅僅在父類增加了虛函數,根據上面的分析,增加虛函數會引入虛函數表指針,指針占8位元組的大小,那為什麼會增加這麼多呢?我們可以藉助工具來分析一下,編譯器一般會提供一些輔助分析工具供開發人員使用,其中有一個功能是把每個類的佈局給列印出來,gcc、clang、vs都有類似的命令,clang可以使用下麵的命令來查看:

clang -Xclang -fdump-record-layouts -stdlib=libc++ -std=c++11 -c filename.cpp

輸出的結果很多,我截取關鍵的一部分:
image
上圖中,左邊的數字就是對象的成員相對於對象的起始地址的偏移量。從上圖我們可以得出以下的結論:

1.父類中各有一個虛函數表以及一個指向它的虛函數表指針,子類分別從父類中繼承下來,父類有多少個虛函數表,子類就有多少個虛函數表。這裡額外插一句,子類雖然繼承了父類的虛函數表,但子類的虛函數表不會和父類的虛函數表是同一個,就運算元類沒有覆蓋父類的任何虛函數,編譯器也會複製多一份虛函數表出來,儘管它們的虛函數表的內容是一模一樣的,但是一般情況下子類都會覆蓋父類的虛函數,不然也沒有必要用虛函數了,虛函數具體的分析以後再講。

2.編譯器為了訪問效率選擇了8位元組的對齊,也就是說成員變數b1占了8位元組,數據本身占了4位元組,為了對齊填充了4位元組,使得下一個虛函數表指針可以對齊訪問。

所以,分析的結論就是子類對象的記憶體佈局是這樣的,首先是從Base1父類繼承來的虛函數表指針,占用8位元組,接著是繼承來的b1成員變數,加上填充的4位元組共占用了8位元組,再接著是從父類Base2繼承來的虛函數表指針,占用8位元組,之後是繼承的b2成員變數,占用4位元組,子類自己的成員變數d緊跟著排列在後面,總共32位元組。佈局示意圖如下:
image

虛繼承的對象的記憶體佈局

虛繼承是為瞭解決棱形繼承情形下重覆繼承的問題提出來的解決辦法,如下麵的代碼:

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

class Grand {
    int a;
};

class Base1: public Grand {
};

class Base2: public Grand {
};

class Derived: public Base1, public Base2 {
};

int main() {
    Grand g;
    Base1 b1;
    Base2 b2;
    Derived obj;
    //obj.a = 1;	// 這行編譯不過。
    printf("The size of g is: %lu\n", sizeof(g));
    printf("The size of b1 is: %lu\n", sizeof(b1));
    printf("The size of b2 is: %lu\n", sizeof(b2));
    printf("The size of obj is: %lu\n", sizeof(obj));
    return 0;
}

上面的代碼中如果不把第23行代碼屏蔽掉是編譯不過的,因為Base1和Base2都繼承了Grand,Derived又繼承了Base1和Base2,Grand中的成員a將會被重覆繼承兩次,這時在子類Derived中就存在了兩個成員a,這時從Derived訪問a就會出現錯誤,因為編譯器不知道你要訪問的是哪一個a,出現了名字衝突的問題。屏蔽掉第23行後編譯運行,看下輸出結果:

The size of g is: 4
The size of b1 is: 4
The size of b2 is: 4
The size of obj is: 8

從結果中也可以驗證,子類Derived占了兩倍的大小。為瞭解決像這種重覆繼承了兩次的問題,辦法是引入虛繼承,我們修改下代碼繼續分析:

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

class Grand {
public:
    int a;
};

class Base1: virtual public Grand {
public:
    int b;
};

class Base2: virtual public Grand {
public:
    int c;
};

class Derived: public Base1, public Base2 {
public:
    int d;
};

int main() {
    Grand g;
    Base1 b1;
    Base2 b2;
    Derived obj;
    obj.a = 1;
    printf("The size of g is: %lu\n", sizeof(g));
    printf("The size of b1 is: %lu\n", sizeof(b1));
    printf("The size of b2 is: %lu\n", sizeof(b2));
    printf("The size of obj is: %lu\n", sizeof(obj));
    printf("The address of obj: %p\n", &obj);
    printf("The address of obj.a: %p\n", &obj.a);
    printf("The address of obj.b: %p\n", &obj.b);
    printf("The address of obj.c: %p\n", &obj.c);
    printf("The address of obj.d: %p\n", &obj.d);
    
    return 0;
}

這時訪問Derived類的對象中的成員變數a就沒有衝突了,如上面代碼的第30行,上面代碼的輸出結果:

The size of g is: 4
The size of b1 is: 16
The size of b2 is: 16
The size of obj is: 40
The address of obj: 0x16d70b420
The address of obj.a: 0x16d70b440
The address of obj.b: 0x16d70b428
The address of obj.c: 0x16d70b438
The address of obj.d: 0x16d70b43c

改為虛繼承後,obj.a = 1;這行代碼能編譯通過了,不會出現名字衝突了。我們來看看孫子類Derived的對象的大小,竟然是40位元組,增大了這麼多,還是使用上面的命令來dump出對象的記憶體佈局,結果如下圖,截取部分:
image
這裡先補充一點,虛繼承是藉助於虛基類表來實現,被虛繼承的父類的成員變數會放在虛基類表中,通過在對象中插入的虛基類表指針來訪問虛基類表,有點類似於虛函數表,實現方式不同的編譯器採用不一樣的方式,gcc和clang是虛函數表和虛基類表共用一個表,稱為虛表,所以只需要一個指針指向它,叫做虛表指針,而Windows平臺的Visual Studio是採用兩個表,所以Windows下對象里會有兩個指針,一個虛函數表指針和一個虛基類表指針,虛基類的實現細節後面再詳細分析。

從上圖可以看到,孫子類Derived的對象的記憶體里擁有兩個虛表指針,因為父類Base1和Base2分別虛繼承了爺爺類Grand,每一個虛繼承將會產生一個虛表指針,按照繼承的順序依次排列,首先是Base1子對象的內容,包含了一個虛表指針和成員變數b,b之後會填充4位元組到8位元組對齊,然後是Base2子對象的內容,同樣也包含了一個虛表指針和成員變數c,再之後是孫子類Derived自己的成員變數d,它是緊湊的排列在c之後的,最後是爺爺類Grand中的成員變數a,可以看到虛繼承下來的成員變數被安排到最後的位置了,從列印的地址也可以看出來。佈局示意圖如下:
image

此篇文章同步發佈於我的微信公眾號:C++對象封裝後的記憶體佈局

如果您感興趣這方面的內容,請在微信上搜索公眾號iShare愛分享或者微信號iTechShare並關註,以便在內容更新時直接向您推送。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 抽象工廠模式(Abstract Factory Pattern): 是圍繞一個超級工廠創建其他工廠。該超級工廠又稱為其他工廠的工廠。這種類型的設計模式屬於創建型模式,它提供了一種創建對象的最佳方式。 在抽象工廠模式中,介面是負責創建一個相關對象的工廠,不需要顯式指定它們的類。每個生成的工廠都能按照工 ...
  • 原理 在看雪看到一篇文章:逆向調用QQ截圖NT與WeChatOCR-軟體逆向。裡面說了怎麼調用微信和QQ本地的OCR模型,還有很詳細的分析過程。 我稍微看了下文章,多的也看不懂。大概流程是使用mmmojo.dll這個dll來與WeChatOCR.exe做通信的,也是用它來啟動和關閉WeChatOCR ...
  • C++ 預設參數 預設參數概述 在 C++ 中,函數參數可以擁有預設值。這意味著,在調用函數時,如果省略了某個參數,那麼將使用為該參數指定的預設值。 設置預設參數 預設參數值使用等號 = 符號進行設置,位於參數聲明的類型之後。例如: void myFunction(string country = ...
  • 目錄一、介紹二、安裝三、導入四、基本使用4.1 發送GET 請求4.2 POST請求發送JSON數據4.3 Post 文件上傳4.4 GoRequests 使用代理4.5 Gorequests 使用session五、HTTP服務端代碼 一、介紹 官方文檔 DOC: https://pkg.go.de ...
  • 利用PyTorch訓練模型識別數字+英文圖片驗證碼 摘要:使用深度學習框架PyTorch來訓練模型去識別4-6位數字+字母混合圖片驗證碼(我們可以使用第三方庫captcha生成這種圖片驗證碼或者自己收集目標網站的圖片驗證碼進行針對訓練)。 一、製作訓練數據集 我們可以把需要生成圖片的一些參數放在se ...
  • 本文分享自華為雲社區《從數據到部署使用Plotly和Dash實現數據可視化與生產環境部署》,作者: 檸檬味擁抱。 數據可視化是數據分析中至關重要的一環,它能夠幫助我們更直觀地理解數據併發現隱藏的模式和趨勢。在Python中,有許多強大的工具可以用來進行數據可視化,其中Plotly和Dash是兩個備受 ...
  • Maven的下載安裝配置 Maven是什麼 Maven是基於項目對象模型(POM project object model),可以通過一小段描述信息(配置)來管理項目的構建,報告和文檔的軟體項目管理工具。 通俗的講maven就是專門用於構建和管理項目的工具,他可以幫助我們去下載我們所需要jar包,幫 ...
  • 系統調用 系統調用,顧名思義,說的是操作系統提供給用戶程式調用的一組“特殊”介面。用戶程式可以通過這組“特殊”介面來獲得操作系統內核提供的服務,比如用戶可以通過文件系統相關的調用請求系統打開文件、關閉文件或讀寫文件,可以通過時鐘相關的系統調用獲得系統時間或設置定時器等。 從邏輯上來說,系統調用可被看 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...